Compare commits

..
169 changed files with 232 additions and 17744 deletions
-22
View File
@@ -9,9 +9,6 @@ on:
push:
branches: [main]
env:
PYTHONFAULTHANDLER: "1"
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
@@ -59,9 +56,6 @@ jobs:
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
import runtime from './tools/vendor/comfyui-runtime.json' with { type: 'json' };
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
import crossPack from './tools/vendor/cross-pack-runtime.json' with { type: 'json' };
console.log(`kikoRepository=${crossPack.repository}`);
console.log(`kikoRevision=${crossPack.revision}`);
JS
- name: Check out pinned ComfyUI
uses: actions/checkout@v7
@@ -70,13 +64,6 @@ jobs:
ref: ${{ steps.runtime.outputs.revision }}
path: .ci-comfyui
persist-credentials: false
- name: Check out pinned KikoTools for compatibility
uses: actions/checkout@v7
with:
repository: ${{ steps.runtime.outputs.kikoRepository }}
ref: ${{ steps.runtime.outputs.kikoRevision }}
path: .ci-kikotools
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ steps.runtime.outputs.pythonVersion }}
@@ -97,12 +84,6 @@ jobs:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:comfyui
- name: Independent and combined pack compatibility
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_KIKO_ROOT: ${{ github.workspace }}/.ci-kikotools
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:compat
- name: Upload real-host reports and startup logs
if: always()
uses: actions/upload-artifact@v7
@@ -164,8 +145,5 @@ jobs:
node --input-type=module --check < "$f" || exit 1
done
- name: Install pinned shared test contracts
run: npm ci --prefix tools/vendor --ignore-scripts
- name: Run JS tests
run: node --test tests/js/*.test.js
-69
View File
@@ -128,72 +128,3 @@ 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.
## Prompt sending and cross-pack compatibility
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
contracts against the actual checked-in browser asset. Adapter and lifecycle
units cover role isolation, connected inputs, stale previews, callback recovery,
serialization normalization and cached-page restoration. Browser tab workflows
exercise real BroadcastChannel delivery; `tests/comfyui/prompt-bridge.spec.mjs`
checks the real widgets, execution serialization and renderer transitions.
The existing required real-ComfyUI job also runs `test:compat`: PromptManager
alone, pinned KikoTools alone and both together, each starting in Classic and
Nodes 2.0. It copies tracked source into disposable hosts, preserves independent
configuration and checks edits/reload with both packs present. KikoTools remains
on its current embedded foundation; this does not adopt the Toolkit in that pack.
For local runs, use a clean KikoTools checkout at the revision in
`tools/vendor/cross-pack-runtime.json`, plus the managed-host prerequisites above:
```sh
PM_COMFY_ROOT=/path/to/pinned/comfyui PM_KIKO_ROOT=/path/to/pinned/kikotools \
npm run --prefix tools/vendor test:compat
```
The compatibility runner reserves loopback ports 8193–8195; `PM_COMPAT_PORT`
selects another consecutive unprivileged range. Never run automated tests against
the maintainer's running ComfyUI. This JavaScript host extension needs a full
browser refresh after deployment; its first slice adds no gallery send controls.
### Workspace prompt inspection and sending
`tests/test_api_prompt_inspector.py` covers rooted physical-file identity,
execution/graph/parameter evidence, saved associations, ambiguity, empty negatives,
read-only behavior, bounded PNG/video metadata and corrupt inputs. Run it with
`python -m unittest discover -s tests -p test_api_prompt_inspector.py` in a disposable
checkout. `tests/js/prompt-inspector.test.js` and `prompt-sender.test.js` cover
request races, explicit destination selection, preview tickets and uncertain
acknowledgements. Browser workflows exercise the same controls in Images and
Viewer, including nested modal focus, mobile layouts and per-role sending.
`tests/comfyui/prompt-sender.spec.mjs` sends through the actual workspace UI into
real Classic and Nodes 2.0 widgets and verifies workflow serialization without
loading a workflow or queueing generation.
-3
View File
@@ -45,7 +45,6 @@ from .prompt_manager import ( # noqa: E402
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
@@ -53,7 +52,6 @@ NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
"PromptManagerFocused": PromptManagerFocused,
"PromptManagerSplit": PromptManagerSplit,
"PromptManagerPair": PromptManagerPair,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
@@ -62,7 +60,6 @@ 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",
}
-321
View File
@@ -1,321 +0,0 @@
"""Read-only positive-parent views over existing prompt and pair tables."""
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
OR EXISTS(SELECT 1 FROM generated_images eg WHERE eg.prompt_id=p.id)
OR NOT EXISTS(SELECT 1 FROM prompt_pairs en WHERE en.negative_prompt_id=p.id))"""
_ORDERS = {
"newest": ("COALESCE(julianday(p.created_at),0)", "DESC"),
"oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"),
"name": ("LOWER(p.text)", "ASC"),
"rating": ("COALESCE(p.rating,0)", "DESC"),
}
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _limit(value: int) -> None:
if type(value) is not int or not 1 <= value <= 200:
raise ValueError("limit must be between 1 and 200")
def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]:
if not isinstance(text, str) or len(text) > 500:
raise ValueError("Search text must be a string of at most 500 characters")
if category is not None and (not isinstance(category, str) or len(category) > 200):
raise ValueError("Invalid library category")
if (
not isinstance(tags, list)
or len(tags) > 30
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
):
raise ValueError("Invalid library tags")
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
if text:
parent.append("p.text LIKE ? ESCAPE '\\'")
parent_params.append(f"%{_escape(text)}%")
pair.append(
"(p.text LIKE ? ESCAPE '\\' OR n.text LIKE ? ESCAPE '\\' "
"OR pp.title LIKE ? ESCAPE '\\')"
)
pair_params.extend([f"%{_escape(text)}%"] * 3)
if category is not None:
parent.append("p.category=?")
pair.append("pp.category=?")
parent_params.append(category)
pair_params.append(category)
for tag in tags:
parent.append(
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name=?)"
)
pair.append(
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name=?)"
)
parent_params.append(tag)
pair_params.append(tag)
condition = (
"("
+ (" AND ".join(parent) or "1=1")
+ (
") OR EXISTS(SELECT 1 FROM prompt_pairs pp "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE pp.positive_prompt_id=p.id AND "
)
+ " AND ".join(pair)
+ ")"
)
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
class LibraryOperations:
"""Group reusable negative variants without changing legacy prompt queries."""
def _library_parents(
self,
where: str,
params: list,
suffix: str = "",
sort_expression: str | None = None,
) -> list[dict]:
conn = self.model.get_connection()
rows = conn.execute(
"SELECT p.id,p.text,p.category,p.rating,p.created_at,"
+ (f"{sort_expression} AS _sort," if sort_expression else "")
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
"AND v.archived=0) AS variant_count,"
"(SELECT COUNT(*) FROM generated_images g "
"WHERE g.prompt_id=p.id) AS image_count "
"FROM prompts p WHERE " + where + suffix,
params,
).fetchall()
result = [{**dict(row), "tags": []} for row in rows]
if result:
by_id = {row["id"]: row for row in result}
marks = ",".join("?" for _ in result)
for row in conn.execute(
"SELECT pt.prompt_id,t.name FROM prompt_tags pt "
"JOIN tags t ON t.id=pt.tag_id "
f"WHERE pt.prompt_id IN ({marks}) ORDER BY t.name",
list(by_id),
):
by_id[row["prompt_id"]]["tags"].append(row["name"])
return result
def list_library(
self,
*,
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
) -> dict:
_limit(limit)
if sort not in _ORDERS:
raise ValueError("Unknown library sort order")
where, params = _filter(text, category, [] if tags is None else tags)
conn = self.model.get_connection()
ceiling = conn.execute("SELECT COALESCE(MAX(id),0) FROM prompts").fetchone()[0]
if after is not None:
if (
not isinstance(after, dict)
or set(after) != {"ceiling", "value", "id"}
or type(after["ceiling"]) is not int
or type(after["id"]) is not int
):
raise ValueError("Invalid library cursor")
ceiling = after["ceiling"]
where += " AND p.id<=?"
params.append(ceiling)
total = conn.execute(
"SELECT COUNT(*) FROM prompts p WHERE " + where, params
).fetchone()[0]
expression, direction = _ORDERS[sort]
if after is not None:
operator = ">" if direction == "ASC" else "<"
where += (
f" AND ({expression}{operator}? OR "
f"({expression}=? AND p.id{operator}?))"
)
params.extend([after["value"], after["value"], after["id"]])
rows = self._library_parents(
where,
[*params, limit + 1],
f" ORDER BY {expression} {direction},p.id {direction} LIMIT ?",
sort_expression=expression,
)
has_more = len(rows) > limit
rows = rows[:limit]
next_key = None
if has_more:
next_key = {
"ceiling": ceiling,
"value": rows[-1]["_sort"],
"id": rows[-1]["id"],
}
for row in rows:
row.pop("_sort")
return {
"items": rows,
"total": total,
"has_more": has_more,
"next_key": next_key,
}
def get_library_positive(
self,
positive_id: int,
*,
variant_limit: int = 40,
variant_after: int | None = None,
) -> dict | None:
_limit(variant_limit)
if variant_after is not None and (
type(variant_after) is not int or variant_after <= 0
):
raise ValueError("Invalid variant cursor")
rows = self._library_parents(_ELIGIBLE + " AND p.id=?", [positive_id])
if not rows:
return None
conn = self.model.get_connection()
where = "pp.positive_prompt_id=? AND pp.archived=0"
params = [positive_id]
variant_total = conn.execute(
"SELECT COUNT(*) FROM prompt_pairs pp WHERE " + where, params
).fetchone()[0]
if variant_after is not None:
where += " AND pp.id<?"
params.append(variant_after)
variants = [
dict(row)
for row in conn.execute(
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
"pp.category,pp.title,"
"pp.negative_prompt_id IS NULL AS explicit_empty,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count "
"FROM prompt_pairs pp "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE " + where + " ORDER BY pp.id DESC LIMIT ?",
[*params, variant_limit + 1],
)
]
variants_has_more = len(variants) > variant_limit
variants = variants[:variant_limit]
by_id = {row["id"]: row for row in variants}
for row in variants:
row["tags"] = []
row["explicit_empty"] = bool(row["explicit_empty"])
if by_id:
marks = ",".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 t.id=pt.tag_id "
f"WHERE pt.pair_id IN ({marks}) ORDER BY t.name",
list(by_id),
):
by_id[row["pair_id"]]["tags"].append(row["name"])
unassigned = conn.execute(
"SELECT COUNT(*) FROM generated_images g WHERE g.prompt_id=? "
"AND NOT EXISTS(SELECT 1 FROM image_prompt_pairs ip "
"WHERE ip.image_id=g.id)",
(positive_id,),
).fetchone()[0]
return {
"positive": rows[0],
"variants": variants,
"unassigned_image_count": unassigned,
"variant_total": variant_total,
"variants_has_more": variants_has_more,
"variants_next_key": variants[-1]["id"] if variants_has_more else None,
}
def get_library_options(self) -> dict:
rows = (
self.model.get_connection()
.execute(
"SELECT category FROM (SELECT p.category AS category "
"FROM prompts p WHERE "
+ _ELIGIBLE
+ (" UNION SELECT category FROM prompt_pairs " "WHERE archived=0) ")
+ "WHERE category IS NOT NULL AND TRIM(category)!='' "
"ORDER BY category COLLATE NOCASE,category"
)
.fetchall()
)
return {"categories": [row["category"] for row in rows]}
def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
"""Read at most two links per parent in one bounded page query."""
if (
not isinstance(positive_ids, list)
or len(positive_ids) > 200
or any(type(value) is not int or value <= 0 for value in positive_ids)
):
raise ValueError("Invalid preview parent IDs")
if not positive_ids:
return {}
result = {value: [] for value in positive_ids}
marks = ",".join("?" for _ in positive_ids)
rows = self.model.get_connection().execute(
"WITH ranked AS (SELECT id,prompt_id,image_path,ROW_NUMBER() OVER "
"(PARTITION BY prompt_id ORDER BY id DESC) AS position "
f"FROM generated_images WHERE prompt_id IN ({marks})) "
"SELECT id,prompt_id,image_path FROM ranked WHERE position<=2 "
"ORDER BY prompt_id,id DESC",
positive_ids,
)
for row in rows:
result[row["prompt_id"]].append(dict(row))
return result
def list_library_images(
self,
positive_id: int,
*,
pair_id: int | None = None,
limit: int = 40,
after: int | None = None,
) -> dict:
_limit(limit)
conn = self.model.get_connection()
if (
pair_id is not None
and not conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=? "
"AND positive_prompt_id=? AND archived=0",
(pair_id, positive_id),
).fetchone()
):
raise ValueError("Variant does not belong to this positive prompt")
where, params = "g.prompt_id=?", [positive_id]
if pair_id is not None:
where += (
" AND EXISTS(SELECT 1 FROM image_prompt_pairs ip "
"WHERE ip.image_id=g.id AND ip.pair_id=?)"
)
params.append(pair_id)
total = conn.execute(
"SELECT COUNT(*) FROM generated_images g WHERE " + where, params
).fetchone()[0]
if after is not None:
if type(after) is not int or after <= 0:
raise ValueError("Invalid image cursor")
where += " AND g.id<?"
params.append(after)
rows = conn.execute(
"SELECT g.id,g.image_path FROM generated_images g WHERE "
+ where
+ " ORDER BY g.id DESC LIMIT ?",
[*params, limit + 1],
).fetchall()
return {
"images": [dict(row) for row in rows[:limit]],
"total": total,
"has_more": len(rows) > limit,
"next_key": rows[limit - 1]["id"] if len(rows) > limit else None,
}
+22 -310
View File
@@ -4,17 +4,14 @@ 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 Optional, Set, Tuple
from typing import Set, Tuple
# Import logging system
try:
@@ -32,41 +29,6 @@ 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.
@@ -273,224 +235,6 @@ 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:
"""
@@ -1028,15 +772,6 @@ 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}"
@@ -1079,11 +814,13 @@ class PromptModel:
"""
Replace the live database with the file at src_path.
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.
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.
Args:
src_path: SQLite file to restore
@@ -1096,42 +833,17 @@ class PromptModel:
RuntimeError: If the previous database could not be backed up
OSError: If the restored file could not be copied into place
"""
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))
)
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
+35 -131
View File
@@ -9,8 +9,6 @@ import os
from typing import Optional, List, Dict, Any
from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations
from .library import LibraryOperations
# Import logging system
try:
@@ -148,7 +146,7 @@ def _coerce_file_info(metadata: Any) -> tuple:
)
class PromptDatabase(PairOperations, LibraryOperations):
class PromptDatabase:
"""Database operations class for managing prompts."""
def __init__(self, db_path: Optional[str] = None):
@@ -583,8 +581,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
Returns:
bool: True if deletion was successful, False otherwise
"""
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, [prompt_id])
with self.model.get_connection() as conn:
# First delete related images to avoid foreign key constraint
conn.execute(
"DELETE FROM generated_images WHERE prompt_id = ?", (prompt_id,)
@@ -600,7 +597,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
Returns:
Number of prompts deleted.
"""
with self._pair_transaction() as conn:
with self.model.get_connection() as conn:
# Get IDs first for cascade cleanup
ids = [
r[0]
@@ -610,7 +607,6 @@ class PromptDatabase(PairOperations, LibraryOperations):
]
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})",
@@ -636,9 +632,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"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 "
"SELECT DISTINCT TRIM(category) as category FROM prompts WHERE "
"category IS NOT NULL AND TRIM(category) != '' ORDER BY category"
)
return [row["category"] for row in cursor.fetchall()]
@@ -705,16 +699,16 @@ class PromptDatabase(PairOperations, LibraryOperations):
def get_all_tags(self) -> List[str]:
"""
Get all unique tags linked to a standalone prompt or reusable pair.
Get all unique tags that are in use (linked to at least one prompt).
Returns:
Sorted list of tag names
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"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) "
"SELECT DISTINCT t.name AS tag "
"FROM prompt_tags pt "
"JOIN tags t ON pt.tag_id = t.id "
"ORDER BY t.name"
)
return [row["tag"] for row in cursor.fetchall()]
@@ -790,8 +784,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
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. Counts include separate standalone
and pair references, with owner types kept distinct. Ordered by count/name.
context tags themselves are left out. Ordered by count, then name.
"""
seen = set()
context = []
@@ -806,11 +799,11 @@ class PromptDatabase(PairOperations, LibraryOperations):
if context:
placeholders = ",".join("?" * len(context))
where.append(
"(pt.kind,pt.owner_id) IN ("
" SELECT pt2.kind,pt2.owner_id FROM tag_refs pt2"
"pt.prompt_id IN ("
" SELECT pt2.prompt_id FROM prompt_tags pt2"
" JOIN tags t2 ON t2.id = pt2.tag_id"
f" WHERE t2.name COLLATE NOCASE IN ({placeholders})"
" GROUP BY pt2.kind,pt2.owner_id"
" GROUP BY pt2.prompt_id"
" HAVING COUNT(DISTINCT lower(t2.name)) = ?)"
)
params.extend(context)
@@ -820,11 +813,8 @@ class PromptDatabase(PairOperations, LibraryOperations):
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 tag_refs pt JOIN tags t ON t.id = pt.tag_id"
" FROM prompt_tags 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"
@@ -931,8 +921,6 @@ class PromptDatabase(PairOperations, LibraryOperations):
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,))
@@ -954,14 +942,12 @@ class PromptDatabase(PairOperations, LibraryOperations):
if existing_new:
# Target tag exists — merge: move links, handle conflicts, delete old
new_tag_id = existing_new["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(
"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,))
conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,))
else:
# Simple rename
@@ -980,7 +966,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
"""
Remove a tag from all prompts that use it.
Deleting from tags cascades to standalone and pair tag relationships.
With junction tables, deleting from the tags table cascades to prompt_tags.
Args:
tag_name: Tag name to remove
@@ -1047,27 +1033,20 @@ class PromptDatabase(PairOperations, LibraryOperations):
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"]
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,)
)
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,))
tags_merged += 1
affected += src_count
@@ -1311,7 +1290,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
"""
self.logger.info("Starting duplicate cleanup process with image preservation")
try:
with self._pair_transaction() as conn:
with self.model.get_connection() 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
@@ -1340,11 +1319,6 @@ class PromptDatabase(PairOperations, LibraryOperations):
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:]
@@ -1890,8 +1864,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
Raises:
sqlite3.IntegrityError: Another prompt already has the new hash.
"""
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, [prompt_id])
with self.model.get_connection() as conn:
cursor = conn.execute(
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
(
@@ -1939,8 +1912,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
Number of prompts actually deleted.
"""
count = 0
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, prompt_ids)
with self.model.get_connection() as conn:
for pid in prompt_ids:
conn.execute("DELETE FROM generated_images WHERE prompt_id = ?", (pid,))
cursor = conn.execute("DELETE FROM prompts WHERE id = ?", (pid,))
@@ -2013,7 +1985,7 @@ class PromptDatabase(PairOperations, LibraryOperations):
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.id, gi.image_path, gi.file_path
gi.parameters
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
WHERE gi.image_path = ? OR gi.file_path = ?
@@ -2025,71 +1997,9 @@ class PromptDatabase(PairOperations, LibraryOperations):
f"%{escape_like(os.path.basename(image_path))}",
),
)
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:
row = cursor.fetchone()
if not row:
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
@@ -2100,8 +2010,6 @@ class PromptDatabase(PairOperations, LibraryOperations):
except (json.JSONDecodeError, TypeError):
prompt_meta = None
return {
"pair": pair,
"pair_context": context,
"prompt_id": row[0],
"text": row[1],
"category": row[2],
@@ -2142,15 +2050,11 @@ class PromptDatabase(PairOperations, LibraryOperations):
Returns number of prompts removed.
"""
with self._pair_transaction() as conn:
with self.model.get_connection() 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
@@ -1,136 +0,0 @@
"""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
@@ -1,387 +0,0 @@
"""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
]
-83
View File
@@ -1,83 +0,0 @@
# 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.
- Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206).
- [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208) adds the native Prompt Library: one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. [Durable history storage](prompt-history-storage.md) must be designed and tested before the execution timeline.
- Execution history, tagging/review and System consolidation remain subsequent slices under #196.
## 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
@@ -1,138 +0,0 @@
# 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
@@ -1,105 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
# Durable Prompt History
Status: future proposal tracked in [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); the native library does not migrate execution storage.
## What the current database proves
- `prompts.run_count` and `prompt_pairs.run_count` are aggregate usage counters.
`utils/usage_tracking.py` counts known recipes when ComfyUI dequeues a job,
including cached node executions. Dequeue does not establish successful completion.
- `last_used_at` records the latest aggregate update. The legacy prompt migration
estimated usage from linked images, with a minimum of one; no flag distinguishes
those estimates from newer recorded uses (`database/models.py`).
- `generated_images` identifies linked files; distinct image IDs are image counts,
never run counts. Its default `generation_time` is database insertion time.
- `image_prompt_pairs` stores immutable association provenance. More than one
provenance row can describe the same image and recipe. Its `created_at` is the
association time, potentially an old-image scan.
- `prompt_pairs.created_at` is the saved recipe date. Execution PNG evidence in
`utils/pair_execution.py` records distinct text/composition values without a
durable job ID or timestamp. `PromptTracker` execution IDs remain process-local.
The current UI can show saved negative variants, linked images and aggregate uses
with an estimate caveat. It cannot honestly show chronological execution dots,
repeat-run branches or completed-run counts.
## Smallest useful future migration
Add a durable `run_events` table using a stable host-session and ComfyUI job ID
plus recipe identity. Create started events at dequeue, rather than node encoding,
so cached executions remain visible. Retain nullable recipe references and immutable
raw/effective positive, negative and composition snapshots. Record completion,
failure and interruption only when observed; otherwise retain unknown status.
Add a deduplicated `run_images(run_id, image_id)` relationship. Multiple output
images can belong to one run, and one physical file must not manufacture multiple
runs through repeated metadata evidence. Preserve aggregate counters and existing
image provenance. Do not manufacture historical events from counters, file dates
or scans.
## Required migration and integration tests
Verify old databases upgrade transactionally and idempotently, with rollback on
failure; unchanged counters and associations; cached repeat executions; failed or
cancelled jobs; batched outputs; duplicate callbacks; process restart; unknown
connected inputs; and metadata rescans that create no execution events. Cover real
Classic and Nodes 2.0 hosts before exposing run chronology.
-34
View File
@@ -1,34 +0,0 @@
# Prompt inspection and sending
The opt-in workspace at `/prompt_manager/workspace#/images` shows full positive
and negative prompts in both Image details and the full-image preview. Existing
gallery and metadata tools remain available during the application rebuild.
Each prompt shows its evidence source: recorded execution, workflow inference,
or embedded generation parameters. Path-only saved-library associations are
marked unverified and cannot be sent: a filename alone cannot prove that an image
has not been replaced. Missing, ambiguous, and unreadable prompts remain visible
as distinct states. An explicitly
empty negative is a resolved value and can clear a destination's negative field.
Use the blue paper-plane action beside a prompt, or **Send prompt pair**. Keep a
ComfyUI tab open under the same origin and deployment prefix. Choose a window and
compatible Prompt Manager node, select **Preview changes**, review the complete
before/after text and preserved prepend/append composition, then **Apply to
ComfyUI**. The dialog reports success only after a matching acknowledgement.
It does not load workflows or queue generation. If confirmation is lost, inspect
ComfyUI and preview again before another attempt. Closing the dialog cannot undo
an update already sent.
## Read-only API
`GET /prompt_manager/images/inspect/{relative_path}?root={root_index}` requires
an explicit configured root, including when two roots contain the same filename.
The response contains `image` identity and `prompts.positive` / `prompts.negative`.
Each role has `status` (`resolved`, `absent`, `unresolved`, or `ambiguous`), `text`
(string only when resolved, otherwise null), and `source`. Inspection does not
index files, alter saved prompts, run scans, or decode image pixels. PNG text and
video probe output are bounded; unsupported or malformed evidence remains
unresolved rather than becoming sendable placeholder text.
Python route changes require restarting ComfyUI. Browser assets require a refresh.
-49
View File
@@ -1,49 +0,0 @@
# Prompt sending bridge
Issue #202 establishes the transport and host adapter before the redesigned
inspector displays prompt text. Gallery paper-plane controls and normalized
metadata follow in a separate slice; existing node layouts are unchanged.
## Shared transport
PromptManager consumes the exact Toolkit 0.2.0 artifact recorded in
`web/js/shared/toolkit/provenance.json`. The shared module has no ComfyUI imports.
Its client discovers explicit host sessions and compatible destinations, requests
a preview, and applies that preview using a one-use, client-bound, expiring ticket.
Success requires an acknowledgement with verified destination values.
BroadcastChannel connects independently opened tabs on the same origin and storage
partition. Deployment prefixes have separate channel namespaces. Exact origin,
protocol and peer checks reject unrelated messages. Same-origin scripts are
trusted; this transport is not authentication against other scripts on that origin.
Unsupported messaging is reported without breaking existing ComfyUI nodes.
An apply timeout or abort after posting means the outcome is unknown. It cannot
prove the host cancelled the write. The sender must inspect a fresh destination
preview before deciding to send again; automatic retry must not overwrite edits.
## PromptManager adapter
Classic, Text, Focused and Split accept positive text; +/- accepts either role or
both. Plain CLIP encoders are not assigned roles by title or position. An omitted
role remains untouched; an explicitly empty string clears only the selected
field. Unknown/ambiguous metadata must not become an empty string.
Previews show original and requested text plus positive prepend/append composition.
Connected composition is marked unknown rather than guessing upstream values.
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
values and connections. Ordinary serialization cache synchronization is accepted
only when it preserves live values. Both requested roles are checked before writing.
Widget values, visible inputs, serialization and composed previews are synchronized.
Callback failures restore local fields; arbitrary external callback side effects
cannot be undone. Sending never queues execution or loads/replaces a workflow.
## Lifecycle and validation
Page hiding closes the host and invalidates tickets; restored pages mount one fresh
host. Client disposal clears pending timers/listeners and closes its channel.
Shared protocol contracts run against the actual bundled file, alongside adapter
units, cross-browser tab workflows and real Classic/Nodes 2.0 scenarios. Pinned
PromptManager-only, KikoTools-only and combined disposable hosts verify coexistence
and workflow reload without modifying either live installation.
-111
View File
@@ -1,111 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

-13
View File
@@ -1,13 +0,0 @@
# Native Library visual evidence
Captured from the production workspace using disposable synthetic browser-test
fixtures. No personal images, library, or running ComfyUI state was accessed.
Solid-color media are intentional test images.
- `library-dark.png`: grouped positive shelf, dark appearance.
- `library-light.png`: the same shelf, light appearance.
- `history-selected-negative.png`: selected negative directly below its positive,
saved-variant branches and the filtered shared gallery.
- `history-mobile.png`: the same history at a 390-pixel viewport.
These screenshots document implemented behavior, rather than a design mockup.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

-226
View File
@@ -1,226 +0,0 @@
"""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()
+3 -14
View File
@@ -22,8 +22,6 @@ from aiohttp import web
from PIL import Image
from .prompts import PromptRoutesMixin
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
from .logging_routes import LoggingRoutesMixin
@@ -86,7 +84,6 @@ 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",
@@ -243,8 +240,6 @@ async def _gzip_middleware(request, handler):
class PromptManagerAPI(
PairRoutesMixin,
LibraryRoutesMixin,
PromptRoutesMixin,
ImageRoutesMixin,
AdminRoutesMixin,
@@ -267,12 +262,12 @@ class PromptManagerAPI(
db (PromptDatabase): Database connection and operations handler
"""
def __init__(self, db: PromptDatabase | None = None) -> None:
"""Initialize the API, using an injected database before any cleanup."""
def __init__(self):
"""Initialize the PromptManager API with database connection and cleanup."""
self.logger = get_logger("prompt_manager.api")
self.logger.info("Initializing PromptManager API")
self.db = db if db is not None else PromptDatabase()
self.db = 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)
@@ -366,10 +361,6 @@ 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)
@@ -400,8 +391,6 @@ class PromptManagerAPI(
# ── Register domain-specific routes from mixins ───────────────
self._register_pair_routes(routes)
self._register_library_routes(routes)
self._register_prompt_routes(routes)
self._register_image_routes(routes)
self._register_admin_routes(routes)
+12 -87
View File
@@ -26,12 +26,8 @@ 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:
@@ -44,20 +40,6 @@ 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
@@ -377,10 +359,6 @@ 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)
@@ -1302,14 +1280,6 @@ 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
@@ -1381,7 +1351,7 @@ class AdminRoutesMixin:
media_files = await self._run_in_executor(
self._collect_output_media_sync, output_dirs
)
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
if media_files:
yield self._scan_progress(
5, f"Found {len(media_files)} media files to process...", counts
@@ -1394,9 +1364,7 @@ class AdminRoutesMixin:
f"found={counts['found']}, new_prompts_added={counts['added']}, "
f"images_linked_to_existing={counts['linked']}"
)
job = self._scan_job
final_type = "cancelled" if job and job.cancelled else "complete"
yield {"type": final_type, **counts, "directories": dir_names}
yield {"type": "complete", **counts, "directories": dir_names}
except Exception:
self.logger.exception("Scan error")
@@ -1412,8 +1380,6 @@ 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
@@ -1439,7 +1405,7 @@ class AdminRoutesMixin:
"progress": progress,
"status": status,
"processed": counts.get("processed", 0),
**{key: counts.get(key, 0) for key in SCAN_COUNT_KEYS},
"found": counts.get("found", 0),
}
def _collect_output_media_sync(self, output_dirs):
@@ -1465,64 +1431,25 @@ class AdminRoutesMixin:
def _ingest_scan_batch_sync(self, batch_results):
"""Store the prompts found in a metadata batch (blocking DB work)."""
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
counts = {"processed": 0, "found": 0, "added": 0, "linked": 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, detailed=True)
outcome = self._ingest_scanned_file(media_file, metadata)
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
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)
if outcome in ("added", "linked"):
counts[outcome] += 1
return counts
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
def _ingest_scanned_file(self, media_file, metadata):
"""Save or link one scanned file; returns 'added', 'linked', 'found' or None."""
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
@@ -1538,15 +1465,13 @@ class AdminRoutesMixin:
existing = self.db.get_prompt_by_hash(prompt_hash)
if existing:
try:
image_id = self.db.link_image_to_prompt(
existing["id"], str(media_file), metadata=canonical
)
self.db.link_image_to_prompt(existing["id"], str(media_file))
except Exception as e:
self.logger.error(
f"Failed to link {media_file.name} to existing prompt: {e}"
)
return "found"
return "linked" if image_id else "unchanged"
return "linked"
prompt_id = self.db.save_prompt(
prompt_text,
@@ -1559,7 +1484,7 @@ class AdminRoutesMixin:
if not prompt_id:
return "found"
try:
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
self.db.link_image_to_prompt(prompt_id, str(media_file))
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
return "added"
+4 -293
View File
@@ -1,10 +1,7 @@
"""Image and gallery API routes for PromptManager."""
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import os
import re
@@ -22,18 +19,8 @@ from .prompts import safe_error_message as _safe_error
try:
from ...utils.parallel import map_parallel
from ...utils.prompt_inspector import (
MetadataReadError,
inspect_prompt_roles,
read_inspection_metadata,
)
except ImportError:
from utils.parallel import map_parallel
from utils.prompt_inspector import (
MetadataReadError,
inspect_prompt_roles,
read_inspection_metadata,
)
# Only these file types are ever served by the image routes, regardless of
# what sits inside an allowed directory (defence in depth for the gallery).
@@ -241,70 +228,6 @@ 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."""
@@ -458,10 +381,6 @@ class ImageRoutesMixin:
async def get_image_prompt_route(request):
return await self.get_image_prompt(request)
@routes.get("/prompt_manager/images/inspect/{image_path:.*}")
async def inspect_image_route(request):
return await self.inspect_image(request)
@routes.delete("/prompt_manager/images/{image_id}")
async def delete_image_route(request):
return await self.delete_image(request)
@@ -675,8 +594,6 @@ 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(
{
@@ -712,119 +629,6 @@ 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:
@@ -971,20 +775,7 @@ class ImageRoutesMixin:
async def get_gallery_subfolders(self, request):
"""Get distinct subfolders from gallery output directories."""
try:
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]]
output_dirs = self._get_all_output_dirs()
subfolders = set()
for output_path in output_dirs:
files = await self._get_gallery_files(output_path)
@@ -1004,10 +795,9 @@ class ImageRoutesMixin:
ancestors.add("/".join(parts[:i]))
subfolders.update(ancestors)
payload = {"success": True, "subfolders": sorted(subfolders)}
if workspace:
payload["roots"] = roots
return web.json_response(payload)
return web.json_response(
{"success": True, "subfolders": sorted(subfolders)}
)
except Exception as e:
self.logger.error(f"Subfolders error: {e}")
return web.json_response(
@@ -1642,65 +1432,6 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
async def inspect_image(self, request):
"""Read prompt evidence for one explicitly rooted physical media file."""
roots = list(self._get_all_output_dirs())
try:
index = _workspace_root(request.query, roots)
if index is None:
raise ValueError("Gallery root is required")
except ValueError as error:
return bad_request(str(error))
relative = request.match_info.get("image_path", "")
if (
_has_traversal(relative)
or "\0" in relative
or re.match(r"^[a-zA-Z]:", relative)
):
return _forbidden()
path = Path(roots[index]) / relative
denied = _media_access_error(path, [roots[index]])
if denied is not None:
return denied
if not path.is_file():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
try:
def inspect() -> dict:
try:
metadata = read_inspection_metadata(path)
except MetadataReadError:
return {
role: {"status": "unresolved", "text": None, "source": None}
for role in ("positive", "negative")
}
saved = (
self.db.get_image_prompt_info(str(path), False)
if not metadata
else None
)
return inspect_prompt_roles(metadata, saved)
prompts = await self._run_in_executor(inspect)
return web.json_response(
{
"success": True,
"image": {
"root_index": index,
"path": Path(relative).as_posix(),
"filename": path.name,
},
"prompts": prompts,
}
)
except Exception:
self.logger.exception("Prompt inspection failed")
return web.json_response(
{"success": False, "error": "Prompt inspection failed"}, status=500
)
async def get_image_prompt(self, request):
"""Get prompt information for a specific image path."""
try:
@@ -1798,26 +1529,6 @@ 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 = {}
-255
View File
@@ -1,255 +0,0 @@
"""Read-only native library with bounded, filter-scoped continuation cursors."""
import base64
import binascii
import hashlib
import hmac
import json
import os
import time
from collections import OrderedDict
from collections.abc import Callable
from pathlib import Path
from aiohttp import web
from .images import _canonical, _media_access_error
from .pairs import pair_response
_SECRET = os.urandom(32)
class _NameCursorStore:
"""Full lexical keys stay off URLs, bounded by age, count and UTF-8 bytes."""
def __init__(
self,
ttl: int = 3600,
max_entries: int = 128,
max_bytes: int = 8 * 1024 * 1024,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.clock = clock
self.ttl = ttl
self.max_entries = max_entries
self.max_bytes = max_bytes
self.entries = OrderedDict()
self.size = 0
def _remove(self, reference: str) -> None:
_expires, _scope, raw = self.entries.pop(reference)
self.size -= len(raw)
def _prune(self) -> None:
now = self.clock()
for reference, (expires, _scope, _raw) in list(self.entries.items()):
if expires <= now:
self._remove(reference)
def put(self, key: object, scope: str) -> str:
raw = json.dumps(key, ensure_ascii=False, allow_nan=False).encode()
if len(raw) > min(512 * 1024, self.max_bytes):
raise ValueError("Prompt name exceeds the cursor limit")
self._prune()
while self.entries and (
len(self.entries) >= self.max_entries
or self.size + len(raw) > self.max_bytes
):
self._remove(next(iter(self.entries)))
reference = base64.urlsafe_b64encode(os.urandom(24)).decode()
self.entries[reference] = (self.clock() + self.ttl, scope, raw)
self.size += len(raw)
return reference
def get(self, reference: str, scope: str) -> object:
self._prune()
entry = self.entries.get(reference)
if entry is None or entry[1] != scope:
raise ValueError("Name cursor expired; refresh the library")
self.entries.move_to_end(reference)
return json.loads(entry[2])
_NAME_CURSORS = _NameCursorStore()
def _scope(value: dict) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def _encode(key: object, scope: str, *, name: bool = False) -> str | None:
if key is None:
return None
value = (
{"scope": scope, "reference": _NAME_CURSORS.put(key, scope)}
if name
else {"scope": scope, "key": key}
)
payload = json.dumps(value, separators=(",", ":"), allow_nan=False).encode()
signature = hmac.new(_SECRET, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _decode(token: str | None, scope: str) -> object:
if token is None:
return None
try:
if not 1 <= len(token) <= 4096:
raise ValueError
decoded = base64.b64decode(
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = decoded[:32], decoded[32:]
if not hmac.compare_digest(
signature, hmac.new(_SECRET, payload, hashlib.sha256).digest()
):
raise ValueError
value = json.loads(payload)
if (
set(value) not in ({"scope", "key"}, {"scope", "reference"})
or value["scope"] != scope
):
raise ValueError
if "reference" in value:
return _NAME_CURSORS.get(value["reference"], scope)
return value["key"]
except (ValueError, TypeError, binascii.Error, UnicodeError):
raise ValueError("Invalid library cursor; refresh the library") from None
def _positive_id(request: web.Request) -> int:
value = int(request.match_info["positive_id"])
if not 1 <= value <= 2**63 - 1:
raise ValueError("Invalid positive prompt ID")
return value
def _page_limit(request: web.Request, key: str = "limit") -> int:
value = int(request.query.get(key, "40"))
if not 1 <= value <= 200:
raise ValueError("limit must be between 1 and 200")
return value
def _missing() -> web.Response:
return web.json_response(
{"success": False, "error": "Positive prompt not found"}, status=404
)
class LibraryRoutesMixin:
def _register_library_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/library")(self.list_library)
routes.get("/prompt_manager/library/options")(self.get_library_options)
routes.get("/prompt_manager/library/{positive_id}")(self.get_library_positive)
routes.get("/prompt_manager/library/{positive_id}/images")(
self.get_library_images
)
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
page = []
for record in records:
try:
raw_path = Path(record["image_path"])
if not raw_path.is_absolute():
continue
path = _canonical(raw_path)
denied = _media_access_error(path, roots)
if denied is not None or not path.is_file():
continue
for index, root in enumerate(roots):
canonical_root = _canonical(root)
if path.is_relative_to(canonical_root):
page.append((path, 0, canonical_root, index))
break
except (OSError, ValueError, RuntimeError):
continue
return self._format_workspace_page(page)
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
result = await self._run_in_executor(self.db.get_library_options)
return web.json_response({"success": True, **result})
@pair_response
async def list_library(self, request: web.Request) -> web.Response:
options = {
"text": request.query.get("text", ""),
"category": request.query.get("category"),
"tags": sorted(set(request.query.getall("tag", []))),
"sort": request.query.get("sort", "newest"),
}
scope = _scope({"kind": "parents", **options})
result = await self._run_in_executor(
self.db.list_library,
**options,
limit=_page_limit(request),
after=_decode(request.query.get("cursor"), scope),
)
roots = list(self._get_all_output_dirs())
def previews() -> None:
records = self.db.get_library_previews(
[parent["id"] for parent in result["items"]]
)
for parent in result["items"]:
parent["previews"] = self._library_media(records[parent["id"]], roots)
await self._run_in_executor(previews)
result["next_cursor"] = _encode(
result.pop("next_key"), scope, name=options["sort"] == "name"
)
return web.json_response({"success": True, **result})
@pair_response
async def get_library_positive(self, request: web.Request) -> web.Response:
positive = _positive_id(request)
scope = _scope({"kind": "variants", "positive": positive})
result = await self._run_in_executor(
self.db.get_library_positive,
positive,
variant_limit=_page_limit(request, "variant_limit"),
variant_after=_decode(request.query.get("variant_cursor"), scope),
)
if result is None:
return _missing()
roots = list(self._get_all_output_dirs())
records = await self._run_in_executor(
self.db.list_library_images, result["positive"]["id"], limit=2
)
result["positive"]["previews"] = await self._run_in_executor(
self._library_media, records["images"], roots
)
result["variants_next_cursor"] = _encode(result.pop("variants_next_key"), scope)
return web.json_response({"success": True, **result})
@pair_response
async def get_library_images(self, request: web.Request) -> web.Response:
positive = _positive_id(request)
pair = request.query.get("pair_id")
pair = int(pair) if pair is not None else None
if pair is not None and not 1 <= pair <= 2**63 - 1:
raise ValueError("Invalid variant ID")
roots = list(self._get_all_output_dirs())
scope = _scope(
{
"kind": "images",
"positive": positive,
"pair": pair,
"roots": [str(_canonical(root)) for root in roots],
}
)
if await self._run_in_executor(self.db.get_library_positive, positive) is None:
return _missing()
result = await self._run_in_executor(
self.db.list_library_images,
positive,
pair_id=pair,
limit=_page_limit(request),
after=_decode(request.query.get("cursor"), scope),
)
result["images"] = await self._run_in_executor(
self._library_media, result["images"], roots
)
result["next_cursor"] = _encode(result.pop("next_key"), scope)
return web.json_response({"success": True, **result})
-204
View File
@@ -1,204 +0,0 @@
"""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,11 +6,6 @@ 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
@@ -798,8 +793,6 @@ 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
@@ -847,8 +840,6 @@ 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"},
@@ -1090,8 +1081,6 @@ 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,7 +16,6 @@ class ScanJob:
def __init__(self):
self.last_event = None
self.finished = False
self.cancelled = False
self.task = None
self._subscribers = set()
-186
View File
@@ -1,186 +0,0 @@
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);
});
-104
View File
@@ -1,104 +0,0 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
const moduleUrl = '/prompt_manager/js/shared/toolkit/prompt-bridge.js';
test.beforeEach(async ({ page, browserName }) => {
await startCoverage(page, browserName);
page.bridgeErrors = [];
page.on('pageerror', error => page.bridgeErrors.push(error.message));
await page.goto('/prompt_manager/workspace#/overview');
});
test.afterEach(async ({ page, browserName }) => {
await page.evaluate(() => { window.client?.dispose(); window.host?.dispose(); });
await saveCoverage(page, browserName);
expect(page.bridgeErrors).toEqual([]);
});
async function host(page, namespace = '/', suffix = '') {
await page.evaluate(async ({ moduleUrl, namespace, suffix }) => {
const { createBridgeHost } = await import(moduleUrl + suffix);
window.applied = [];
window.host = createBridgeHost({ origin: location.origin, namespace, label: 'Independent host', adapter: {
listTargets: () => [{ id: 'node-1', label: 'Pair', roles: ['positive', 'negative'] }],
preview: (id, roles) => ({ preview: { target: { id }, fields: roles }, snapshot: structuredClone(roles) }),
apply: roles => { window.applied.push(roles); return { targetId: 'node-1', values: roles }; },
} });
}, { moduleUrl, namespace, suffix });
}
async function client(page, namespace = '/') {
await page.evaluate(async ({ moduleUrl, namespace }) => {
const { createBridgeClient } = await import(moduleUrl);
window.client = createBridgeClient({ origin: location.origin, namespace, timeoutMs: 1000 });
}, { moduleUrl, namespace });
}
test('separately opened tabs preview and acknowledge exact roles through the shipped transport', async ({ page, context }) => {
const destination = await context.newPage();
try {
await destination.goto('/prompt_manager/workspace#/overview');
await host(destination);
await client(page);
const result = await page.evaluate(async () => {
const [host] = await window.client.discover({ waitMs: 400 });
const preview = await window.client.preview(host.hostId, 'node-1', { positive: 'A mountain', negative: '' });
window.previewTicket = preview.ticket;
return { host, preview };
});
expect(result.host.label).toBe('Independent host');
expect(result.preview.fields).toEqual({ positive: 'A mountain', negative: '' });
expect(await destination.evaluate(() => window.applied)).toEqual([]);
const ack = await page.evaluate(hostId => window.client.apply(hostId, window.previewTicket), result.host.hostId);
expect(ack).toEqual({ targetId: 'node-1', values: { positive: 'A mountain', negative: '' } });
expect(await destination.evaluate(() => window.applied)).toEqual([ack.values]);
expect(await page.evaluate(async hostId => {
try { await window.client.apply(hostId, window.previewTicket); return false; } catch { return true; }
}, result.host.hostId)).toBe(true);
} finally {
await destination.evaluate(() => window.host?.dispose()).catch(() => {});
await destination.close();
}
});
test('independent bundled hosts coexist and deployment namespaces isolate discovery', async ({ page, context }) => {
const second = await context.newPage();
try {
await second.goto('/prompt_manager/workspace#/overview');
await host(page, '/', '?bundle=first');
await host(second, '/', '?bundle=second');
await client(page);
const hosts = await page.evaluate(() => window.client.discover({ waitMs: 400 }));
expect(new Set(hosts.map(host => host.hostId)).size).toBe(2);
await page.evaluate(() => window.client.dispose());
await client(page, '/other-deployment/');
expect(await page.evaluate(() => window.client.discover({ waitMs: 400 }))).toEqual([]);
} finally {
await second.evaluate(() => window.host?.dispose()).catch(() => {});
await second.close();
}
});
test('disconnected hosts, cancellation and disposal reject pending requests cleanly', async ({ page }) => {
await host(page);
await client(page);
const errors = await page.evaluate(async () => {
const [host] = await window.client.discover({ waitMs: 400 });
window.host.dispose();
const errors = [];
try { await window.client.preview(host.hostId, 'node-1', { positive: 'No destination' }); } catch (error) { errors.push(error.message); }
const controller = new AbortController();
const aborted = window.client.preview(host.hostId, 'node-1', { negative: 'Cancelled' }, { signal: controller.signal });
controller.abort();
try { await aborted; } catch (error) { errors.push(error.message); }
const pending = window.client.preview(host.hostId, 'node-1', { positive: 'Disposed' });
window.client.dispose();
try { await pending; } catch (error) { errors.push(error.message); }
return errors;
});
expect(errors).toHaveLength(3);
expect(errors[0]).toMatch(/timed out/);
expect(errors[1]).toMatch(/abort/);
expect(errors[2]).toMatch(/disposed/);
});
-205
View File
@@ -1,205 +0,0 @@
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 details=page=>page.getByRole('region',{name:'Image details',exact:true});
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click();
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{prompt_inspector:true}})).toBeOK();
page.promptErrors=[];page.on('pageerror',error=>page.promptErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.promptErrors).toEqual([]);});
async function fakeHost(context,page,{rejectApply=false}={}) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async rejectApply=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after})),composition:{prepend:'trigger',append:'detail',prependConnected:false,appendConnected:false,before:`trigger ${window.hostValues.positive} detail`,after:`trigger ${roles.positive??window.hostValues.positive} detail`}},snapshot:roles}),
apply:roles=>{if(rejectApply)throw new Error('Workflow changed. Preview again.');Object.assign(window.hostValues,roles);window.applied.push(roles);return{targetId:target.id,values:roles};},
}});
},rejectApply);
return host;
}
async function chooseDestination(page) {
const sender=dialog(page);await expect(sender).toBeVisible();
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
await host.selectOption({label:'Test ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
return sender;
}
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
});
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
await page.goto(workspace);await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
});
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
let fail=true;let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/images/inspect/**',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.pathname.endsWith('same.png')){
if(fail){return route.fulfill({status:503,json:{success:false,error:'Temporarily unavailable'}});}
await held;
}
await route.continue().catch(()=>{});
});
await page.goto(workspace);await select(page,'0:shared/same.png');
await expect(details(page)).toContainText('Could not read prompts from this file.');
fail=false;await details(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
release();await expect(role(details(page),'Positive')).toContainText('Dog landscape');
});
test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
expect(await host.evaluate(()=>window.applied)).toEqual([]);
await page.screenshot({path:testInfo.outputPath('sender-preview.png')});
await chosen.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(chosen).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:'Cat portrait',negative:'blurry, watermark'}]);
await chosen.getByRole('button',{name:'Close',exact:true}).click();
await page.evaluate(()=>scrollTo(0,0));
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
await page.getByRole('button',{name:'Close preview',exact:true}).click();
await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
const host=await fakeHost(context,page,{rejectApply:true});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('empty negative send clears only the negative destination and preserves its positive',async({page,context})=>{
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:empty-negative.png');
await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
const host=await fakeHost(context,page);
try {
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Cat portrait',negative:'Existing negative'});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('mobile inspector exposes full long prompts, truthful uncertainty and copy failure feedback',async({page})=>{
const text=`A long detailed prompt ${'with readable full composition, '.repeat(150)}END OF FULL PROMPT`;
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
});
test('filtering away a selected image aborts its pending metadata request',async({page})=>{
await page.addInitScript(()=>{
const fetch=window.fetch;window.inspectionAborts=0;
window.fetch=function(input,options){
if(String(input).includes('/images/inspect/')&&options?.signal)options.signal.addEventListener('abort',()=>window.inspectionAborts++,{once:true});
return fetch.call(this,input,options);
};
});
let started;const requested=new Promise(resolve=>{started=resolve;});let release;
const hold=new Promise(resolve=>{release=resolve;});
await page.route('**/images/inspect/**',async route=>{started();await hold;await route.continue().catch(()=>{});});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
const baseline=await page.evaluate(()=>window.inspectionAborts);
await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
} finally {release();}
});
test('responsive gallery defers observed size writes and still fits the new viewport',async({page})=>{
await page.addInitScript(()=>{
const Native=window.ResizeObserver;window.inlineResizeWrites=0;
window.ResizeObserver=class extends Native {
constructor(callback){super((entries,observer)=>{
const sizes=entries.map(entry=>entry.target.style.height);
callback(entries,observer);
entries.forEach((entry,index)=>{
if(entry.target.classList.contains('image-grid')&&entry.target.style.height!==sizes[index])window.inlineResizeWrites++;
});
});}
};
});
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.image-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeGreaterThan(900);
expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
});
test('unverified saved associations explain uncertainty and cannot be sent',async({page})=>{
await page.route('**/images/inspect/uncertain.png?root=0',route=>route.fulfill({json:{success:true,prompts:{
positive:{status:'unresolved',text:null,source:'saved-association',message:'A saved library association exists, but it cannot be verified against this file.'},
negative:{status:'absent',text:null,source:null},
}}}));
await page.goto(workspace);await select(page,'0:uncertain.png');
await expect(role(details(page),'Positive')).toContainText('cannot be verified against this file');
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
});
+4 -139
View File
@@ -78,37 +78,15 @@ def create_app(root: Path) -> web.Application:
api = PromptManagerAPI()
api.add_routes(routes)
def seed(
extra_images: int = 0,
include_video: bool = False,
prompt_inspector: bool = False,
native_library: bool = False,
extra_prompts: int = 0,
extra_variants: int = 0,
) -> None:
def seed(extra_images: int = 0) -> None:
with api.db.model.get_connection() as conn:
for table in (
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompts",
):
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
for index, (directory, text, tag) in enumerate(
[
("output", "Cat portrait", "cats"),
(
"input",
"Cat portrait" if native_library else "Dog landscape",
"dogs",
),
]
[("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")]
):
image_path = root / directory / "shared" / "same.png"
image_path.parent.mkdir(exist_ok=True)
@@ -121,25 +99,6 @@ def create_app(root: Path) -> web.Application:
)
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
image.save(image_path, pnginfo=info)
if prompt_inspector or native_library:
graph = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "blurry, watermark" if index == 0 else "low quality"
},
},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
},
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
}
info = PngInfo()
info.add_text("prompt", json.dumps(graph))
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
image.save(image_path, pnginfo=info)
stamp = time.time() - index * 10
os.utime(image_path, (stamp, stamp))
pid = api.db.save_prompt(
@@ -147,119 +106,25 @@ 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))
if native_library:
positive = api.db.get_prompt_by_hash(generate_prompt_hash("Cat portrait"))
positive_id = positive["id"]
api.db.update_prompt_metadata(
positive_id, category="Animals", tags=["cats", "studio"]
)
variants = []
for index, negative in enumerate(
["blurry, watermark", "low quality", "low contrast", ""]
):
pair_id = api.db.save_prompt_pair(
"Cat portrait",
negative,
title=f"Portrait variant {index + 1}",
category="Portraits" if index == 0 else "Experiments",
tags=["studio"] if index == 0 else ["outdoors"],
)
variants.append(pair_id)
with api.db.model.get_connection() as conn:
images = conn.execute(
"SELECT id FROM generated_images WHERE prompt_id=? ORDER BY id",
(positive_id,),
).fetchall()
for index, image in enumerate(images[:2]):
api.db.link_image_to_pair(
variants[index], image["id"], {"source": "fixture", "anchor": index}
)
api.db.link_image_to_pair(
variants[0], images[0]["id"], {"source": "fixture second evidence"}
)
api.db.record_prompt_pair_use(variants[0], times=3)
for number in range(extra_variants):
api.db.save_prompt_pair("Cat portrait", f"Negative extra {number:04}")
legacy = root / "output" / "legacy-unassigned.png"
shutil.copyfile(root / "output" / "shared" / "same.png", legacy)
api.db.link_image_to_prompt(positive_id, str(legacy))
api.db.save_prompt("Dog landscape", category="Animals", tags=["dogs"])
api.db.save_prompt_pair("Dual role portrait", "Cat portrait")
for number in range(extra_prompts):
api.db.save_prompt(
f"Library extra {number:04}", category="Bulk", tags=["bulk"]
)
if prompt_inspector:
for name, parameters in (
(
"empty-negative.png",
"Explicit empty negative\nNegative prompt: \n"
"Steps: 20, Sampler: Euler",
),
("uncertain.png", None),
):
info = PngInfo()
if parameters:
info.add_text("parameters", parameters)
with Image.new("RGB", (64, 64), (100, 80, 120)) as image:
image.save(root / "output" / name, pnginfo=info)
os.utime(root / "output" / name, (time.time() - 100, time.time() - 100))
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)
prompt_inspector = data.get("prompt_inspector", False)
native_library = data.get("native_library", False)
extra_prompts = data.get("extra_prompts", 0)
extra_variants = data.get("extra_variants", 0)
if (
isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
or not isinstance(include_video, bool)
or not isinstance(prompt_inspector, bool)
or not isinstance(native_library, bool)
or isinstance(extra_prompts, bool)
or not isinstance(extra_prompts, int)
or not 0 <= extra_prompts <= 200
or isinstance(extra_variants, bool)
or not isinstance(extra_variants, int)
or not 0 <= extra_variants <= 200
):
raise web.HTTPBadRequest()
await api._run_in_executor(
seed,
extra,
include_video,
prompt_inspector,
native_library,
extra_prompts,
extra_variants,
)
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)
await api._run_in_executor(seed, extra)
return web.json_response({"success": True})
@routes.get("/api/settings/Comfy.ColorPalette")
-482
View File
@@ -1,482 +0,0 @@
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);
});
-170
View File
@@ -1,170 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const media=page=>gallery(page).locator('article[data-image-key]');
async function openHistory(page,text='Cat portrait') {
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await expect(history(page)).toBeVisible();
}
async function selectVariant(page,index) {
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{
await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1);
await expect(cards(page).filter({has:page.getByText('Dual role portrait',{exact:true})})).toHaveCount(1);
await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0);
const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})});
await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i);
await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i);
});
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
await page.goto(route);await openHistory(page);await expect(media(page)).toHaveCount(3);
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
await expect(neg).toContainText(negative||'Empty negative prompt');
await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
await expect(positive).toContainText('Cat portrait');
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
expect(order&4).toBeTruthy();
}
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
});
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('');
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits');
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1);
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
});
test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:125}})).toBeOK();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
expect(await cards(page).count()).toBeLessThan(128);
let hasMore=true;
while(hasMore) {
const nextPage=page.waitForResponse(response=>{const url=new URL(response.url());return url.pathname==='/prompt_manager/library'&&Boolean(url.searchParams.get('cursor'));});
await more.focus();await page.keyboard.press('Enter');
const appended=await (await nextPage).json();expect(appended.items.length).toBeGreaterThan(0);hasMore=appended.has_more;
if(hasMore)await expect(more).toHaveAttribute('aria-disabled','false');
}
await expect(more).toBeHidden();expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
const firstId=await cards(page).first().getAttribute('data-prompt-id');
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true});
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/library?*',async request=>{
const url=new URL(request.request().url());
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
if(url.searchParams.get('text')==='Cat')await hold;
await request.continue().catch(()=>{});
});
await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{
await page.setViewportSize({width:390,height:844});await page.goto(route);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
await openHistory(page);await selectVariant(page,0);
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await history(page).getByRole('link',{name:'Back to library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route(/\/prompt_manager\/library\/\d+(?:\?|$)/,async route=>{
if(fail)return route.fulfill({status:503,json:{success:false,error:'Unavailable'}});
await hold;await route.continue().catch(()=>{});
});
try {
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
await page.getByRole('link',{name:'Back to library',exact:true}).click();release();
await expect(cards(page)).toHaveCount(3);await expect(history(page)).toHaveCount(0);
} finally {release();}
});
test('history shares real image metadata, vendored Viewer and the acknowledged prompt sender',async({page,context,request})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'pair-1',label:'Library destination',type:'PromptManagerPair',roles:['positive','negative']};
window.libraryApplied=[];
window.libraryHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Library ComfyUI',adapter:{
listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}),
apply:roles=>{window.libraryApplied.push(roles);return{targetId:target.id,values:roles};},
}});
});
try {
await page.goto(route);
const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id');
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await saved(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Library destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(sender).toContainText(chosenNegative||'(empty)');
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
await inspector.getByRole('button',{name:'Open full preview',exact:true}).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 page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
});
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
await page.goto(route);await openHistory(page);
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative);
});
-218
View File
@@ -1,218 +0,0 @@
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');
});
-46
View File
@@ -1,46 +0,0 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {installHostHooks} from './host-fixture.mjs';
installHostHooks(test,['PromptManagerPair']);
test('native library sends the selected saved pair into a real ComfyUI destination',async({page,context,request},testInfo)=>{
const positive=`Native library host ${testInfo.project.name}`;const negative=`Saved negative ${testInfo.project.name}`;
const response=await request.post('/prompt_manager/pairs',{data:{positive_text:positive,negative_text:negative,title:'Host saved pair',category:'Native host',tags:['library-host']}});
expect(response.ok()).toBe(true);
const id=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);node.setSize([700,1000]);
app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id;
});
const editor=page.locator('.pm-pair');await editor.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Original positive');
await editor.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Original negative');
await editor.getByText('Composition · prepend / append',{exact:true}).click();
await editor.getByRole('textbox',{name:'Prepend',exact:true}).fill('Keep trigger');await editor.getByRole('textbox',{name:'Append',exact:true}).fill('Keep detail');
await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');window.libraryEffects={queue:0,load:0};
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];app[method]=function(...args){window.libraryEffects[key]++;return original.apply(this,args);};}
});
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace#/library');
await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive);
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
await history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender).toContainText(positive);await expect(sender).toContainText(negative);await expect(sender).toContainText(`Keep trigger ${positive} Keep detail`);
await expect(editor.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Original positive');
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
await expect(editor.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(positive);await expect(editor.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(negative);
const state=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.libraryEffects};
},id);
for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:positive,negative_text:negative,prepend_text:'Keep trigger',append_text:'Keep detail'});
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
} finally {await workspace.close();}
});
-87
View File
@@ -1,87 +0,0 @@
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);
await expect.poll(async()=>{
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));
return Boolean(p&&n&&p.width>0&&p.height>0&&n.height>0&&n.y>p.y+p.height-1&&Math.abs(p.width-n.width)<1);
},{message:'Narrow prompt editors stack with equal widths after canvas resize'}).toBe(true);
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);
});
-192
View File
@@ -1,192 +0,0 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {installHostHooks} from './host-fixture.mjs';
const TYPES=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
installHostHooks(test,TYPES);
async function addNode(page,type,values={}) {
return page.evaluate(async({type,values})=>{
const {app}=await import('/scripts/app.js');
app.graph.clear();
const node=LiteGraph.createNode(type);app.graph.add(node);
node.setSize([type==='PromptManagerSplit'?1000:700,1050]);
app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
for(const [name,value]of Object.entries(values)) {
const widget=node.widgets.find(widget=>widget.name===name);
widget.value=value;if(widget.inputEl)widget.inputEl.value=value;
widget.callback?.(value,app.canvas,node);
}
return node.id;
},{type,values});
}
async function withClient(page,context,run) {
const clientPage=await context.newPage();const errors=[];
clientPage.on('pageerror',error=>errors.push(error.message));
try {
await clientPage.goto(new URL('/prompt_manager/workspace#/overview',page.url()).href,{waitUntil:'domcontentloaded'});
await clientPage.evaluate(async()=>{
const {createBridgeClient}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
window.bridgeClient=createBridgeClient({origin:location.origin,namespace:'/'});
});
await run(clientPage);
expect(errors).toEqual([]);
} finally {
await clientPage.evaluate(()=>window.bridgeClient?.dispose()).catch(()=>{});
await clientPage.close();
}
}
async function destination(clientPage,id) {
const hosts=await clientPage.evaluate(()=>window.bridgeClient.discover({waitMs:200}));
const host=hosts.find(host=>host.targets.some(target=>String(target.id)===String(id)));
expect(host,'independently opened workspace discovers the real host').toBeDefined();
const target=host.targets.find(target=>String(target.id)===String(id));
return {hostId:host.hostId,target};
}
async function preview(clientPage,hostId,target,payload) {
return clientPage.evaluate(async({hostId,target,payload})=>{
window.bridgePreview=await window.bridgeClient.preview(hostId,target.id,payload);
return window.bridgePreview;
},{hostId,target,payload});
}
async function apply(clientPage,hostId) {
return clientPage.evaluate(hostId=>window.bridgeClient.apply(hostId,window.bridgePreview.ticket),hostId);
}
async function rejection(clientPage,action) {
return clientPage.evaluate(async action=>{
try {
if(action.kind==='preview')await window.bridgeClient.preview(action.hostId,action.targetId,action.payload);
else await window.bridgeClient.apply(action.hostId,window.bridgePreview.ticket);
return null;
} catch(error) {return String(error.message);}
},action);
}
async function snapshot(page,id) {
return page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
const serialized=node.serialize();
return {
widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
inputs:Object.fromEntries(node.widgets.filter(widget=>widget.inputEl).map(widget=>[widget.name,widget.inputEl.value])),
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),
prompt:(await app.graphToPrompt()).output[String(id)].inputs,
};
},id);
}
async function connect(page,id,field) {
return page.evaluate(async({id,field})=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name===field);
if(!node.inputs.some(input=>input.name===field))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.widgets.find(widget=>widget.name==='value').value=`Upstream ${field}`;
source.connect(0,node,node.inputs.findIndex(input=>input.name===field));
return [String(source.id),0];
},{id,field});
}
test('independent workspace discovers all Prompt Manager destinations and writes real widgets without executing or loading workflows',async({page,context})=>{
await withClient(page,context,async clientPage=>{
for(const type of TYPES) {
const id=await addNode(page,type,{text:'Before bridge'});
const draft=`Visible draft ${type}`;
await page.locator('textarea:visible').first().fill(draft);
const {hostId,target}=await destination(clientPage,id);
expect(target.type).toBe(type);expect(target.label).toBeTruthy();expect(target.roles).toContain('positive');
expect(target.roles.includes('negative')).toBe(type==='PromptManagerPair');
await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');window.bridgeSideEffects={queue:0,load:0};
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]) {
const original=app[method];app[method]=function(...args){window.bridgeSideEffects[key]++;return original.apply(this,args);};
}
});
const text=`Bridge sent to ${type}`;const proposed=await preview(clientPage,hostId,target,{positive:text});
expect(proposed.ticket).toBeTruthy();expect(proposed.fields).toContainEqual({role:'positive',before:draft,after:text});
expect((await snapshot(page,id)).widgets.text).toBe(draft);
expect(await apply(clientPage,hostId)).toMatchObject({targetId:target.id,values:{positive:text}});
await expect.poll(()=>page.locator('textarea:visible').evaluateAll(inputs=>inputs.map(input=>input.value))).toContain(text);
const state=await snapshot(page,id);
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
expect(await page.evaluate(()=>window.bridgeSideEffects)).toEqual({queue:0,load:0});
}
});
});
test('pair sends preserve omitted negatives, accept an empty negative and synchronize visible editors and composition',async({page,context})=>{
const id=await addNode(page,'PromptManagerPair',{text:'Old positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
await withClient(page,context,async clientPage=>{
const {hostId,target}=await destination(clientPage,id);
const proposed=await preview(clientPage,hostId,target,{positive:'New positive'});
expect(proposed.composition).toMatchObject({prepend:'LoRA trigger',append:'fine detail',prependConnected:false,appendConnected:false,before:'LoRA trigger Old positive fine detail',after:'LoRA trigger New positive fine detail'});
await apply(clientPage,hostId);
expect((await snapshot(page,id)).widgets).toMatchObject({text:'New positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
const root=page.locator('.pm-pair');
await expect(root.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('New positive');
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Keep negative');
await root.getByText('Composed text preview',{exact:true}).click();
await expect(root.locator('[data-composed-preview]')).toHaveText('LoRA trigger New positive fine detail');
await preview(clientPage,hostId,target,{positive:'Paired positive',negative:''});
await apply(clientPage,hostId);
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('');
const state=await snapshot(page,id);
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name]).toMatchObject({text:'Paired positive',negative_text:''});
});
});
test('connected requested fields reject atomically while connected composition remains linked',async({page,context})=>{
const id=await addNode(page,'PromptManagerPair',{text:'Keep positive',negative_text:'Keep negative',prepend_text:'Local prepend'});
const negativeLink=await connect(page,id,'negative_text');const prependLink=await connect(page,id,'prepend_text');
await withClient(page,context,async clientPage=>{
const {hostId,target}=await destination(clientPage,id);
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Must not apply',negative:'Blocked'}})).toMatch(/connected/i);
expect((await snapshot(page,id)).widgets).toMatchObject({text:'Keep positive',negative_text:'Keep negative'});
const proposed=await preview(clientPage,hostId,target,{positive:'Allowed positive'});
expect(proposed.composition.prependConnected).toBe(true);
await apply(clientPage,hostId);
expect((await snapshot(page,id)).prompt).toMatchObject({text:'Allowed positive',negative_text:negativeLink,prepend_text:prependLink});
const positiveLink=await connect(page,id,'text');
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Blocked positive'}})).toMatch(/connected/i);
expect((await snapshot(page,id)).prompt.text).toEqual(positiveLink);
});
});
test('user edits and workflow replacement invalidate acknowledged previews without overwriting changes',async({page,context})=>{
const id=await addNode(page,'PromptManagerFocused',{text:'Original draft'});
await withClient(page,context,async clientPage=>{
let {hostId,target}=await destination(clientPage,id);
await preview(clientPage,hostId,target,{positive:'Pending change'});
await page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true}).fill('User edited draft');
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
({hostId,target}=await destination(clientPage,id));
await preview(clientPage,hostId,target,{positive:'Pending after replacement'});
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
});
});
test('bridge remains live through both renderer switches and workflow reload with synchronized Focused controls',async({page,context},testInfo)=>{
const id=await addNode(page,'PromptManagerFocused',{text:'Initial',prepend_text:'trigger',append_text:'detail'});
await withClient(page,context,async clientPage=>{
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);
const {hostId,target}=await destination(clientPage,id);const text=`Renderer ${renderer}`;
await preview(clientPage,hostId,target,{positive:text});await apply(clientPage,hostId);
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(text);
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
const state=await snapshot(page,id);
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
}
const {hostId,target}=await destination(clientPage,id);
await preview(clientPage,hostId,target,{positive:'After reload'});await apply(clientPage,hostId);
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('After reload');
});
});
-68
View File
@@ -1,68 +0,0 @@
import {execFileSync} from 'node:child_process';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {installHostHooks} from './host-fixture.mjs';
installHostHooks(test,['PromptManagerPair']);
test('native image inspector previews and sends a pair into real visible widgets without loading or queueing',async({page,context,request},testInfo)=>{
const positive=`Inspector host positive ${testInfo.project.name}`;const negative='host negative, watermark';
const image=execFileSync(process.env.PM_TEST_PYTHON||'python',['-c',[
'import io,sys',
'from PIL import Image',
'from PIL.PngImagePlugin import PngInfo',
'info=PngInfo()',
'info.add_text("parameters",sys.argv[1]+"\\nNegative prompt: "+sys.argv[2]+"\\nSteps: 20, Sampler: Euler")',
'buffer=io.BytesIO()',
'Image.new("RGB",(64,64),(30,80,140)).save(buffer,format="PNG",pnginfo=info)',
'sys.stdout.buffer.write(buffer.getvalue())',
].join('\n'),positive,negative]);
const filename=`sender-${testInfo.project.name}.png`;
const upload=await request.post('/upload/image',{multipart:{image:{name:filename,mimeType:'image/png',buffer:image},type:'output',subfolder:'prompt-sender',overwrite:'true'}});
expect(upload.ok()).toBe(true);
const id=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
node.setSize([700,1000]);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
return node.id;
});
const nodeRoot=page.locator('.pm-pair');
await nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Before positive');
await nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Before negative');
await nodeRoot.getByText('Composition · prepend / append',{exact:true}).click();
await nodeRoot.getByRole('textbox',{name:'Prepend',exact:true}).fill('LoRA trigger');
await nodeRoot.getByRole('textbox',{name:'Append',exact:true}).fill('fine detail');
await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');window.senderEffects={queue:0,load:0};
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]){
const original=app[method];app[method]=function(...args){window.senderEffects[key]++;return original.apply(this,args);};
}
});
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace#/images');
await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
await inspector.getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);
await hosts.selectOption({index:1});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender).toContainText('Sent to ComfyUI.');
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(positive);
await expect(nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(negative);
const state=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.senderEffects};
},id);
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
} finally {await workspace.close();}
});
-49
View File
@@ -1,49 +0,0 @@
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();
}
});
@@ -1,92 +0,0 @@
import {readFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with {type:'json'};
const bridgeSource=await readFile(new URL('../../web/js/shared/toolkit/prompt-bridge.js',import.meta.url),'utf8');
test('independent and combined packs preserve real controls, bridge isolation and workflow state',async({page,context,request},testInfo)=>{
const {mode,initialRenderer}=testInfo.project.metadata;
const hasPM=mode!=='kiko',hasKiko=mode!=='promptmanager';const errors=[];
page.on('pageerror',error=>errors.push(error.message));
const response=await request.get('/system_stats');expect(response.ok()).toBe(true);
expect((await response.json()).system.comfyui_version).toBe(runtime.serverVersion);
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':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()=>Boolean((await import('/scripts/app.js')).app.graph));
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(initialRenderer);
const definitions=await(await request.get('/object_info')).json();
expect(Boolean(definitions.PromptManagerFocused)).toBe(hasPM);expect(Boolean(definitions.TextInput)).toBe(hasKiko);
await page.waitForFunction(({hasPM,hasKiko})=>
(!hasPM||Boolean(LiteGraph.registered_node_types.PromptManagerFocused))&&
(!hasKiko||Boolean(LiteGraph.registered_node_types.TextInput)),{hasPM,hasKiko});
const ids=await page.evaluate(async({hasPM,hasKiko})=>{
const {app}=await import('/scripts/app.js');app.graph.clear();const ids={};
for(const [key,type]of [...(hasPM?[['pm','PromptManagerFocused']]:[]),...(hasKiko?[['kiko','TextInput']]:[])]) {
const node=LiteGraph.createNode(type);app.graph.add(node);node.setSize([680,1000]);
node.pos=[key==='kiko'?800:0,0];ids[key]=node.id;
}
app.canvas.ds.scale=.8;app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));return ids;
},{hasPM,hasKiko});
const clientPage=await context.newPage();clientPage.on('pageerror',error=>errors.push(error.message));
try {
await clientPage.goto(new URL(hasPM?'/prompt_manager/workspace#/overview':'/system_stats',page.url()).href,{waitUntil:'domcontentloaded'});
await clientPage.evaluate(async source=>{
const url=URL.createObjectURL(new Blob([source],{type:'text/javascript'}));
try {const {createBridgeClient}=await import(url);window.packClient=createBridgeClient({origin:location.origin,namespace:'/'});}
finally {URL.revokeObjectURL(url);}
},bridgeSource);
for(const [index,renderer]of [initialRenderer,!initialRenderer,initialRenderer].entries()) {
await page.evaluate(async({renderer,ids})=>{
const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);
app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));
},{renderer,ids});
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
const kikoText=`Kiko independent edit ${index}`;
if(hasKiko) {
const control=page.locator('textarea:visible:not(.pm-focused textarea)');
await expect(control).toHaveCount(1);await expect(control).toBeVisible();await control.fill(kikoText);
expect(await control.evaluate(async(_element,id)=>{
const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs.text;
},ids.kiko)).toBe(kikoText);
}
const hosts=await clientPage.evaluate(()=>window.packClient.discover({waitMs:200}));
expect(hosts).toHaveLength(hasPM?1:0);
if(hasPM) {
expect(hosts[0].targets).toHaveLength(1);
const target=hosts[0].targets[0];expect(String(target.id)).toBe(String(ids.pm));
expect(target.type).toBe('PromptManagerFocused');
const text=`PM isolated send ${index}`;
const result=await clientPage.evaluate(async({hostId,target,text})=>{
const preview=await window.packClient.preview(hostId,target.id,{positive:text});
return window.packClient.apply(hostId,preview.ticket);
},{hostId:hosts[0].hostId,target,text});
expect(result.values.positive).toBe(text);
const state=await page.evaluate(async ids=>{
const {app}=await import('/scripts/app.js');const prompt=(await app.graphToPrompt()).output;
return {pm:prompt[String(ids.pm)].inputs.text,kiko:ids.kiko?prompt[String(ids.kiko)].inputs.text:null};
},ids);
expect(state.pm).toBe(text);if(hasKiko)expect(state.kiko).toBe(kikoText);
}
await page.evaluate(async ids=>{
const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());
app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));
},ids);
if(hasKiko)expect(await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs.text;
},ids.kiko)).toBe(kikoText);
}
expect(errors).toEqual([]);
} finally {
await clientPage.evaluate(()=>window.packClient?.dispose()).catch(()=>{});await clientPage.close();
}
});
-14
View File
@@ -1,14 +0,0 @@
# 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.
-61
View File
@@ -1,61 +0,0 @@
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);
}
});
-52
View File
@@ -1,52 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createScope} from '../../web/js/shared/toolkit/core.js';
import * as module from '../../web/js/workspace/prompt-inspector.mjs';
function dom() {
class Element {
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};}
append(...children){for(const child of children){child.parent=this;this.children.push(child);}}
replaceChildren(...children){this.children=[];this.append(...children);}
setAttribute(key,value){this.attributes[key]=value;}
addEventListener(type,listener,{signal}={}){this.listeners.set(type,listener);signal?.addEventListener('abort',()=>this.listeners.delete(type),{once:true});}
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
click(target){this.listeners.get('click')?.({target});}
}
const previous=global.document;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
return {Element,walk,restore:()=>{global.document=previous;}};
}
const record=text=>({status:'resolved',text,source:'library'});
test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('<literal>\nfull positive'),negative:{status:'unresolved',message:'Choose a saved negative.'}}});
assert.ok(f.walk(container,node=>node.textContent==='<literal>\nfull positive').length);
assert.equal(f.walk(container,node=>node.dataset.sendRole==='pair').length,0);
view.update({positive:record('Complete positive'),negative:record('')});
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]);
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2);
view.dispose();container.click(pair);assert.equal(sent.length,1);
}finally{scope.dispose();f.restore();}
});
test('positive-only cards never display or send a hidden negative or pair',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,roles:['positive'],prompts:{positive:record('positive'),negative:record('hidden')},sender:{open:roles=>sent.push(roles)}});
assert.equal(f.walk(container,node=>node.attributes['aria-label']==='Negative prompt').length,0);
assert.equal(f.walk(container,node=>node.dataset.sendRole==='pair').length,0);
const button=f.walk(container,node=>node.dataset.sendRole==='positive')[0];container.click(button);assert.deepEqual(sent,[{positive:'positive'}]);
scope.dispose();container.click(button);assert.equal(sent.length,1);
}finally{scope.dispose();f.restore();}
});
test('disposed blocks ignore updates and aborted parents never mount content',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('original')}});
view.dispose();view.update({positive:record('late')});assert.equal(f.walk(container,node=>node.textContent==='late').length,0);
scope.dispose();const other=new f.Element('div');module.mountPromptBlocks(other,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('late')}});assert.equal(other.children.length,0);
}finally{scope.dispose();f.restore();}
});
-142
View File
@@ -1,142 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/prompt-bridge-adapter.js');
function fixture(type='PromptManagerPair') {
const widgets = ['text','negative_text','prepend_text','append_text','category','tags'].map((name,index) => ({name,value:['old','bad',' start ',' end ','cat','tag'][index],inputEl:{value:['old','bad',' start ',' end ','cat','tag'][index]}}));
const node = {id:7,type,title:'My prompts',widgets,widgets_values:widgets.map(w=>w.value),inputs:[],_focusedView:{render(){node.rendered=(node.rendered||0)+1;}},setDirtyCanvas(){}};
const app = {graph:{_nodes:[node],_version:1,setDirtyCanvas(){}},canvas:{}};
return {app,node,widget:name=>node.widgets.find(w=>w.name===name)};
}
test('lists only explicit PromptManager destinations and roles for all five schemas',async()=>{
const {createPromptAdapter}=await source;
const types=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
const nodes=types.map((type,id)=>({...fixture(type).node,id})); nodes.push({...fixture().node,id:8,type:'CLIPTextEncode'});
const targets=createPromptAdapter({graph:{_nodes:nodes}}).listTargets();
assert.deepEqual(targets.map(t=>t.type),types);
assert.deepEqual(targets.map(t=>t.roles),types.map(type=>type==='PromptManagerPair'?['positive','negative']:['positive']));
assert.equal(targets[0].id,'0'); assert.equal(targets[0].label,'My prompts');
assert.deepEqual(createPromptAdapter({}).listTargets(),[]);
});
test('previews exact composition, updates requested visible/serialized values, preserves other fields',async()=>{
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app);
const {preview,snapshot}=adapter.preview('7',{positive:'new'});
assert.deepEqual(preview.fields,[{role:'positive',before:'old',after:'new'}]);
assert.deepEqual(preview.composition,{prepend:' start ',append:' end ',prependConnected:false,appendConnected:false,before:'start old end',after:'start new end'});
let callback; widget('text').callback=(value,canvas,target)=>{callback=[value,canvas,target];};
assert.deepEqual(adapter.apply(snapshot),{targetId:'7',values:{positive:'new'}});
assert.equal(widget('text').inputEl.value,'new'); assert.equal(node.widgets_values[0],'new'); assert.equal(node.rendered,1);
assert.deepEqual(callback,['new',app.canvas,node]);
for(const [name,value] of [['negative_text','bad'],['prepend_text',' start '],['append_text',' end '],['category','cat'],['tags','tag']]) assert.equal(widget(name).value,value);
assert.throws(()=>adapter.apply(snapshot),/snapshot|preview/i);
});
test('omitted positive stays intact, empty negative clears explicitly',async()=>{
const {createPromptAdapter}=await source; const {app,widget}=fixture(); const adapter=createPromptAdapter(app);
adapter.apply(adapter.preview('7',{negative:''}).snapshot);
assert.equal(widget('text').value,'old'); assert.equal(widget('negative_text').value,'');
});
test('rejects invalid roles, unsupported targets, missing/connected fields before mutation',async()=>{
const {createPromptAdapter}=await source; const {app,node,widget}=fixture('PromptManagerText'); const adapter=createPromptAdapter(app);
for(const roles of [null,{},[],{positive:2},{positive:'x',other:'y'},{negative:'x'},Object.create({positive:'x'})]) assert.throws(()=>adapter.preview('7',roles));
assert.throws(()=>adapter.preview('99',{positive:'x'}));
node.inputs=[{name:'text',link:0}]; assert.throws(()=>adapter.preview('7',{positive:'x'}),/connect/i);
node.inputs=[]; node.widgets=node.widgets.filter(w=>w.name!=='text'); assert.throws(()=>adapter.preview('7',{positive:'x'}),/widget/i);
assert.equal(widget('negative_text').value,'bad');
});
test('connected composition is unknown and never overwritten',async()=>{
const {createPromptAdapter}=await source; const {app,node}=fixture(); node.inputs=[{name:'prepend_text',link:0}];
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'x'});
assert.equal(result.preview.composition.before,null); assert.equal(result.preview.composition.after,null); assert.equal(result.preview.composition.prependConnected,true);
adapter.apply(result.snapshot); assert.equal(node.inputs[0].link,0);
});
test('rejects stale workflow, node, widget, values, serialization, visible values, and connections',async()=>{
const {createPromptAdapter}=await source;
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.app.graph._version++;},f=>{f.app.graph._nodes=[{...f.node}];},f=>{f.node.type='PromptManagerText';},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='changed';},f=>{f.widget('text').inputEl.value='typing';},f=>{f.node.widgets_values[0]='changed';},f=>{f.node.inputs.push({name:'text',link:2});},f=>{f.node.inputs.push({name:'append_text',link:2});}];
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'}); mutate(f); assert.throws(()=>adapter.apply(snapshot),/changed|stale/i); assert.notEqual(f.widget('text').value,'new');}
});
test('validates both roles before writing and rejects forged snapshots',async()=>{
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
node.inputs.push({name:'negative_text',link:3}); assert.throws(()=>adapter.apply(snapshot)); assert.equal(widget('text').value,'old'); assert.throws(()=>adapter.apply({}));
});
test('callback failure or transformed value rolls all requested values back without an acknowledgement',async()=>{
const {createPromptAdapter}=await source;
for(const fail of ['throw','transform','visible','serialization']) {
const {app,node,widget}=fixture(),adapter=createPromptAdapter(app);
widget('negative_text').callback=()=>{if(fail==='throw') throw new Error('callback failed'); if(fail==='transform') widget('text').value='altered'; if(fail==='visible') widget('text').inputEl.value='altered'; if(fail==='serialization') node.widgets_values[0]='altered';};
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new',negative:'newbad'}).snapshot),/failed|changed|match/i);
assert.equal(widget('text').value,'old'); assert.equal(widget('text').inputEl.value,'old'); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('negative_text').value,'bad');
}
});
test('snapshots isolate caller mutations and require the originating adapter',async()=>{
const {createPromptAdapter}=await source; const {app,widget}=fixture(),adapter=createPromptAdapter(app),roles={positive:'new'};
const result=adapter.preview('7',roles); roles.positive='changed'; result.preview.fields[0].after='changed';
assert.throws(()=>createPromptAdapter(app).apply(result.snapshot)); adapter.apply(result.snapshot); assert.equal(widget('text').value,'new');
});
test('applies positive text for every supported schema without a serialization array',async()=>{
const {createPromptAdapter}=await source;
for(const type of ['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair']) {
const {app,node,widget}=fixture(type); delete node.widgets_values; delete node._focusedView; delete node.setDirtyCanvas; delete app.graph.setDirtyCanvas;
const adapter=createPromptAdapter(app); adapter.apply(adapter.preview('7',{positive:''}).snapshot);
assert.equal(widget('text').value,''); assert.equal(node.widgets_values[0],'');
}
});
test('matches Python whitespace composition and validates the combined one MiB UTF-8 role limit',async()=>{
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.title=''; widget('prepend_text').value=' '; widget('append_text').value='';
const adapter=createPromptAdapter(app);
assert.equal(adapter.preview('7',{positive:' x '}).preview.composition.after,' x ');
assert.equal(adapter.preview('7',{positive:''}).preview.composition.after,'');
assert.equal(adapter.preview('7',{positive:'x'.repeat(1_048_576)}).preview.fields[0].after.length,1_048_576);
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(1_048_577)}));
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(524_288),negative:'y'.repeat(524_289)}));
assert.throws(()=>adapter.preview('7',{positive:'é'.repeat(524_289)}));
assert.equal(adapter.preview('7',{positive:'é'.repeat(524_288)}).preview.fields[0].after.length,524_288);
assert.throws(()=>adapter.preview('7',{get positive(){throw new Error('Getter must not run');}}),/strings/i);
assert.throws(()=>adapter.preview('7',{positive:'x',[Symbol('unknown')]:'y'}));
assert.equal(adapter.preview('7',Object.assign(Object.create(null),{positive:'x'})).preview.fields[0].after,'x');
assert.equal(adapter.listTargets()[0].label,'PromptManagerPair');
});
test('callback changes to graph, connections, widgets or protected prompt fields fail and restore fields',async()=>{
const {createPromptAdapter}=await source;
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.node.inputs=[{name:'text',link:4}];},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='unexpected';},f=>{f.widget('prepend_text').value='unexpected';},f=>{f.widget('category').value='unexpected';}];
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app); f.widget('text').callback=()=>mutate(f); assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i); assert.equal(f.widget('negative_text').value,'bad'); assert.equal(f.widget('prepend_text').value,' start '); assert.equal(f.widget('category').value,'cat');}
});
test('minimal classic node without optional widgets remains a valid destination',async()=>{
const {createPromptAdapter}=await source; const {app,node}=fixture('PromptManager'); node.widgets=node.widgets.slice(0,1); node.widgets_values=['old']; delete node.widgets[0].inputEl;
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'new'});
assert.equal(result.preview.composition.before,'old'); assert.equal(result.preview.composition.after,'new');
assert.deepEqual(adapter.apply(result.snapshot).values,{positive:'new'});
});
test('callback removal of serialization or replacement of visible controls cannot acknowledge success',async()=>{
const {createPromptAdapter}=await source;
for(const mutate of [f=>{delete f.node.widgets_values;},f=>{f.widget('text').inputEl={value:'new'};},f=>{f.node.id=8;},f=>{f.node.widgets.reverse();},f=>{delete f.node.widgets;},f=>{f.node.widgets=[];}]) {
const f=fixture(),adapter=createPromptAdapter(f.app); const originalInput=f.widget('text').inputEl;
f.widget('text').callback=()=>mutate(f);
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i);
assert.equal(f.widget('text').value,'old'); assert.equal(originalInput.value,'old'); assert.equal(f.node.widgets_values[0],'old');
}
});
test('benign host serialization may establish or normalize equivalent serialized values after preview',async()=>{
const {createPromptAdapter}=await source;
for (const initial of ['absent','sparse','old']) {
const {app,node,widget}=fixture();
if(initial==='absent') delete node.widgets_values;
if(initial==='sparse') node.widgets_values=[];
if(initial==='old') node.widgets_values[0]='earlier serialized draft';
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
// ComfyUI serialize() materializes the live widget values without editing them.
node.widgets_values=node.widgets.map(item=>item.inputEl ? item.inputEl.value : item.value);
assert.deepEqual(adapter.apply(snapshot).values,{positive:'new',negative:'newbad'});
assert.equal(widget('text').value,'new');
}
});
test('serialization normalization still rejects a different execution value',async()=>{
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); delete node.widgets_values;
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
node.widgets_values=node.widgets.map(item=>item.value); node.widgets_values[0]='unapproved';
assert.throws(()=>adapter.apply(snapshot),/changed/i); assert.equal(widget('text').value,'old');
});
test('callback failure preserves serialization normalized between preview and apply',async()=>{
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.widgets_values[0]='earlier draft';
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
});
-4
View File
@@ -1,4 +0,0 @@
import * as bridge from '../../web/js/shared/toolkit/prompt-bridge.js';
import { registerPromptBridgeContracts } from '../../tools/vendor/node_modules/@comfyassets/toolkit/testing/prompt-bridge-contracts.mjs';
registerPromptBridgeContracts(bridge, () => import('../../web/js/shared/toolkit/prompt-bridge.js?independent-consumer=0.1'));
-68
View File
@@ -1,68 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installPromptBridge } from '../../web/js/prompt-bridge-host.js';
function fixture() {
const runtime = new EventTarget();
runtime.location = { origin: 'https://comfy.example', href: 'https://comfy.example/team/' };
runtime.document = { title: 'My workflow' };
const warnings = []; runtime.console = { warn: (...args) => warnings.push(args) };
const hosts = [], adapter = {};
const options = { runtime, baseUrl: '/team/prompt_manager/', adapterFactory: () => adapter,
hostFactory: config => { const host = { config, disposed: 0, dispose() { this.disposed++; } }; hosts.push(host); return host; } };
return { runtime, options, hosts, adapter, warnings };
}
test('host uses the exact deployment prefix and explicit pack label', () => {
const f = fixture(), app = {};
const controller = installPromptBridge(app, f.options);
assert.equal(f.hosts.length, 1);
assert.equal(f.hosts[0].config.namespace, '/team/');
assert.equal(f.hosts[0].config.origin, 'https://comfy.example');
assert.equal(f.hosts[0].config.label, 'PromptManager — My workflow');
assert.equal(f.hosts[0].config.adapter, f.adapter);
assert.equal(installPromptBridge(app, f.options), controller);
controller.dispose(); controller.dispose();
assert.equal(f.hosts[0].disposed, 1);
});
test('page hide disposes and cached restoration mounts exactly one fresh host', () => {
const f = fixture(), controller = installPromptBridge({}, f.options);
f.runtime.dispatchEvent(new Event('pagehide'));
assert.equal(f.hosts[0].disposed, 1);
f.runtime.dispatchEvent(new Event('pageshow'));
f.runtime.dispatchEvent(new Event('pageshow'));
assert.equal(f.hosts.length, 2);
controller.dispose();
f.runtime.dispatchEvent(new Event('pageshow'));
assert.equal(f.hosts.length, 2);
assert.equal(f.hosts[1].disposed, 1);
});
test('a disposed installation can be installed again', () => {
const f = fixture(), app = {};
installPromptBridge(app, f.options).dispose();
const next = installPromptBridge(app, f.options);
assert.equal(f.hosts.length, 2);
next.dispose();
});
test('unavailable messaging leaves the existing host usable and can recover after restoration', () => {
const f = fixture(); let unavailable = true;
const factory = f.options.hostFactory;
f.options.hostFactory = config => { if (unavailable) throw new Error('BroadcastChannel unavailable'); return factory(config); };
const controller = installPromptBridge({}, f.options);
assert.equal(f.warnings.length, 1);
unavailable = false;
f.runtime.dispatchEvent(new Event('pageshow'));
assert.equal(f.hosts.length, 1);
controller.dispose();
});
test('deployment namespace rejects external origins and unexpected paths', () => {
for (const baseUrl of ['https://comfy.example.attacker.test/prompt_manager/', '/wrong/', 'data:text/plain,hello']) {
const f = fixture();
assert.throws(() => installPromptBridge({}, { ...f.options, baseUrl }), /deployment|origin|path/i);
assert.equal(f.hosts.length, 0);
}
});
-36
View File
@@ -1,36 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { inspectPath, promptRoles, createInspectionLoader } from '../../web/js/workspace/prompt-inspector.mjs';
test('inspection keeps exact physical root and encodes every path component', () => {
assert.equal(inspectPath({root_index:2,path:'a b/x#%.png'}), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2');
});
test('only confirmed strings can be sent, including explicit empty negatives', () => {
const record = {positive:{status:'resolved',text:'full\npositive'},negative:{status:'resolved',text:''}};
assert.deepEqual(promptRoles(record), {positive:'full\npositive',negative:''});
for (const status of ['absent','ambiguous','unresolved','other']) {
assert.deepEqual(promptRoles({positive:{status,text:'placeholder'},negative:{status:'resolved',text:null}}), {});
}
assert.deepEqual(promptRoles(null), {});
});
test('superseded requests and disposed loaders cannot replace current metadata', async () => {
const pending = [], changes = [];
const loader = createInspectionLoader({get:(url,{signal})=>new Promise(resolve=>pending.push({url,signal,resolve}))}, value=>changes.push(value));
const first=loader.load({root_index:0,path:'one.png'});
const second=loader.load({root_index:1,path:'one.png'});
assert.equal(pending[0].signal.aborted,true);
pending[1].resolve({prompts:{positive:{status:'resolved',text:'latest'}}}); await second;
pending[0].resolve({prompts:{positive:{status:'resolved',text:'old'}}}); await first;
assert.equal(changes.at(-1).prompts.positive.text,'latest');
const third=loader.load({root_index:0,path:'third.png'}); loader.dispose();
assert.equal(pending[2].signal.aborted,true);
pending[2].resolve({prompts:{positive:{status:'resolved',text:'disposed'}}}); await third;
assert.equal(changes.at(-1).loading,true);
});
test('failed reads report a retryable error and later reads recover', async () => {
let fail=true; const changes=[];
const loader=createInspectionLoader({get:async()=>{if(fail)throw Error('bad');return {prompts:{}};}},value=>changes.push(value));
await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).error,true);
fail=false; await loader.load({root_index:0,path:'one.png'}); assert.deepEqual(changes.at(-1).prompts,{});
loader.dispose(); await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).loading,undefined);
});
-121
View File
@@ -1,121 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/prompt-sender.mjs');
const hosts = [{hostId:'host',label:'Workflow',targets:[{id:'one',label:'Positive',roles:['positive']},{id:'pair',label:'Pair',roles:['positive','negative']}]}];
const tick = () => new Promise(resolve => setImmediate(resolve));
function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
const calls = [];
const client = {discover:async () => hosts, preview:async (...args) => {calls.push(['preview',...args]);return {ticket:'ticket',target:{id:args[1]},fields:[{role:'positive',before:'old',after:'new'}],composition:{before:'prefix old',after:'prefix new'}};},apply:async (...args) => {calls.push(['apply',...args]);return {targetId:'one',values:{positive:'new'}};},dispose:() => calls.push(['dispose']),...overrides};
const states = []; const session = createSendSession({client,roles,onChange:state=>states.push(state)});
return {client,calls,states,session};
}
test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
const {createSendSession} = await source; const s=setup(createSendSession);
await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
await s.session.preview(); assert.equal(s.calls.length,0);
s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
await s.session.apply();assert.equal(s.calls.length,0);
await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
});
test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
});
test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
const {createSendSession}=await source;let resolve;const s=setup(createSendSession,{preview:()=>new Promise(done=>resolve=done)});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');const pending=s.session.preview();
s.session.selectTarget('pair');resolve({ticket:'old'});await pending;assert.equal(s.session.state.preview,null);
s.session.selectTarget('one');const pending2=s.session.preview();s.session.dispose();const count=s.states.length;resolve({ticket:'old'});await pending2;assert.equal(s.states.length,count);
});
test('unknown apply outcome never retries a ticket and requires a new preview',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>{throw Error('timed out');}});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/may have applied/i);assert.equal(s.session.state.preview,null);
await s.session.apply();assert.equal(s.session.state.phase,'error');await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
});
test('only matching explicit acknowledgements count as success',async()=>{
const {createSendSession}=await source;
for(const ack of [undefined,{targetId:'other',values:{positive:'new'}},{targetId:'one',values:{positive:'wrong'}}]){
const s=setup(createSendSession,{apply:async()=>ack});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');s.session.dispose();
}
});
test('discovery, empty results and stale preview errors remain actionable',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
});
test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
});
test('pending apply explains closing cannot undo the sent update',async()=>{
const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
});
function fakeDOM() {
class Node extends EventTarget {
constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
append(...children){this.children.push(...children);}
add(option){this.append(option);}
replaceChildren(...children){this.children=children;}
setAttribute(key,value){this.attributes[key]=value;}
showModal(){this.open=true;}
close(){this.open=false;this.dispatchEvent(new Event('close'));}
remove(){this.isConnected=false;}
focus(){this.focused=true;}
}
const previous={document:global.document,window:global.window,Option:global.Option};
const body=new Node('body');
global.document={body,createElement:tag=>new Node(tag),createElementNS:(namespace,tag)=>new Node(tag)};
global.Option=class extends Node {constructor(text,value){super('option');this.textContent=text;this.value=value;}};
global.window={location:new URL('https://example.test/prefix/prompt_manager/workspace'),ApiClient:{resolveUrl:()=>'/prefix/prompt_manager/'}};
const find=(root,predicate)=>predicate(root)?root:root.children.map(child=>find(child,predicate)).find(Boolean);
return {body,Node,find,restore(){Object.assign(global,previous);}};
}
test('native sender dialog uses prefixed namespace, full safe text, explicit choices and owned cleanup',async()=>{
const {createPromptSender,createSendButton}=await source;
const {createScope}=await import('../../web/js/shared/toolkit/core.js');
const dom=fakeDOM();const scope=createScope();const trigger=new dom.Node('button');let options,disposed=0;
const client={discover:async()=>hosts,preview:async()=>({ticket:'t',target:{id:'one'},fields:[{role:'positive',before:'',after:'<unsafe>new</unsafe>'}],composition:{prepend:'prefix',append:'suffix',before:null,after:null,prependConnected:true}}),apply:async()=>({targetId:'one',values:{positive:'<unsafe>new</unsafe>'}}),dispose:()=>disposed++};
try {
const button=createSendButton('Send positive prompt');assert.equal(button.attributes['aria-label'],'Send positive prompt');assert.equal(button.children[0].attributes['aria-hidden'],'true');
const sender=createPromptSender({scope,clientFactory:value=>{options=value;return client;}});sender.open({positive:'<unsafe>new</unsafe>'},trigger);await tick();
assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='<unsafe>new</unsafe>'));
const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to ComfyUI.'));scope.dispose();assert.equal(dialog.isConnected,false);assert.equal(trigger.focused,true);assert.equal(disposed,1);
sender.open({positive:'new'},trigger);assert.equal(dom.body.children.length,1);
} finally {scope.dispose();dom.restore();}
});
test('unsupported client or deployment errors are visible and close stays available',async()=>{
const {createPromptSender}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');const dom=fakeDOM();
try {
for(const baseUrl of ['https://other.test/prompt_manager/','/prefix/not-prompt-manager/','/prefix/prompt_manager/']) {
const scope=createScope();const sender=createPromptSender({scope,baseUrl,clientFactory:()=>{throw Error('BroadcastChannel unavailable');}});
sender.open({positive:'new'});const dialog=dom.body.children.at(-1);const alert=dom.find(dialog,node=>node.attributes.role==='alert');assert.match(alert.textContent,/unavailable|Invalid/);
dom.find(dialog,node=>node.textContent==='Close').dispatchEvent(new Event('click'));assert.equal(dialog.isConnected,false);scope.dispose();
}
} finally {dom.restore();}
});
test('approval requires an exact destination and complete unambiguous matching preview',async()=>{
const {createSendSession}=await source;
const valid={ticket:'ticket',target:{id:'one'},fields:[{role:'positive',before:'old',after:'new'}]};
for(const preview of [null,{}, {...valid,ticket:''},{...valid,target:{id:'other'}},{...valid,fields:[]},{...valid,fields:new Array(1)},{...valid,fields:[null]},{...valid,fields:[...valid.fields,...valid.fields]},{...valid,fields:[{role:'negative',before:'old',after:'new'}]},{...valid,fields:[{role:'positive',before:null,after:'new'}]},{...valid,fields:[{role:'positive',before:'old',after:'unexpected'}]}]) {
const s=setup(createSendSession,{preview:async()=>preview});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.preview,null);await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);s.session.dispose();
}
});
test('acknowledgement cannot claim an extra role beyond approved changes',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
});
-27
View File
@@ -76,30 +76,3 @@ 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');
});
@@ -1,23 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createScope} from '../../web/js/shared/toolkit/core.js';
import {mountImages} from '../../web/js/workspace/images.mjs';
test('Images mounted after its parent is aborted creates no DOM, requests or observers',()=>{
const parent=createScope();parent.dispose();
const originals=new Map(['document','window','ResizeObserver','IntersectionObserver'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
const touches=[];
const forbidden=name=>new Proxy({}, {get(target,key){touches.push(`${name}.${String(key)}`);throw Error(`Disposed mount accessed ${name}.${String(key)}`);}});
Object.defineProperty(globalThis,'document',{configurable:true,value:forbidden('document')});
Object.defineProperty(globalThis,'window',{configurable:true,value:forbidden('window')});
for(const key of ['ResizeObserver','IntersectionObserver']) Object.defineProperty(globalThis,key,{configurable:true,value:class {constructor(){touches.push(key);throw Error('Observer created after parent disposal');}}});
const api={get(){touches.push('request');assert.fail('Request started after parent disposal');}};
try {
let mounted;
assert.doesNotThrow(()=>{mounted=mountImages(forbidden('container'),api,parent,{embedded:true,fetchPage(){touches.push('page');assert.fail('Page requested after parent disposal');}});});
assert.equal(typeof mounted.dispose,'function');mounted.dispose();mounted.dispose();
assert.deepEqual(touches,[]);
} finally {
for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
}
});
-169
View File
@@ -1,169 +0,0 @@
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);
});
test('custom collection pages reuse image-stream cancellation, cursors and identity checks',async()=>{
const {createImageStream}=await source;const calls=[];
const stream=createImageStream({fetchPage:async({parameters,signal})=>{calls.push({parameters,signal});return calls.length===1?page([image('linked.png')],2,'next'):page([image('other.png')],2);},batchSize:1});
assert.equal(await stream.reset(),1);assert.equal(calls[0].parameters.get('limit'),'1');assert.ok(calls[0].signal instanceof AbortSignal);
assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose();
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
});
-80
View File
@@ -1,80 +0,0 @@
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
@@ -1,49 +0,0 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const source=import('../../web/js/workspace/library-state.mjs');
const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]});
const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor});
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
});
test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{
const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}});
const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]);
const third=s.reset();s.dispose();requests[2].reject(Error('late'));await third;assert.equal(await s.loadMore(),0);
});
test('failed pages retain cursor/data and can retry while duplicate load is guarded',async()=>{
const {createLibraryStream}=await source;let calls=0,release;const s=createLibraryStream({api:{get:async()=>{calls++;if(calls===1)return page([item(1)],'next');if(calls===2)throw Error('offline');return new Promise(resolve=>release=resolve);}}});
await s.reset();await s.loadMore();assert.equal(s.state.cursor,'next');assert.equal(s.state.items.length,1);assert.match(s.state.error.message,/offline/);const request=s.loadMore();assert.equal(await s.loadMore(),0);release(page([item(2)]));await request;assert.equal(s.state.error,null);assert.equal(s.state.items.length,2);s.dispose();
});
test('malformed/nonprogressing pages fail atomically and invalid filters never send requests',async()=>{
const {createLibraryStream}=await source;
for(const malformed of [{},page([{...item(1),id:-1}]),{...page([]),has_more:true},page([{...item(1),text:null}])]){const s=createLibraryStream({api:{get:async()=>malformed}});await s.reset();assert.ok(s.state.error);assert.equal(s.state.items.length,0);s.dispose();}
const s=createLibraryStream({api:{get:async()=>page([],'same')}});await s.reset();await s.loadMore();assert.ok(s.state.error);s.dispose();
const invalid=createLibraryStream({api:{get:()=>assert.fail()}});for(const filters of [{sort:'bad'},{text:2},{tags:'a'}])await assert.rejects(invalid.reset(filters));invalid.dispose();
});
test('library detail selection preserves full strings and distinguishes all, empty and unknown',async()=>{
const {libraryPromptRoles}=await source;const detail={positive:item(1),variants:[{id:9,negative_text:'',explicit_empty:true},{id:10,negative_text:'long\nnegative',explicit_empty:false}]};
const all=libraryPromptRoles(detail,null);assert.equal(all.positive.text,'Positive 1');assert.equal(all.negative.status,'unresolved');assert.equal(libraryPromptRoles(detail,9).negative.text,'');assert.equal(libraryPromptRoles(detail,10).negative.text,'long\nnegative');assert.throws(()=>libraryPromptRoles(detail,999));
});
test('detail reads validate identities, abort stale selection and expose retryable errors',async()=>{
const {createLibraryDetail}=await source;const pending=[],states=[];const loader=createLibraryDetail({api:{get:(url,{signal})=>new Promise((resolve,reject)=>pending.push({url,signal,resolve,reject}))},onChange:state=>states.push(state)});
const first=loader.load(1);const second=loader.load(2);assert.equal(pending[0].signal.aborted,true);pending[1].resolve({positive:item(2),variants:[],unassigned_image_count:0});await second;pending[0].resolve({positive:item(1),variants:[]});await first;assert.equal(states.at(-1).detail.positive.id,2);
const third=loader.load(3);pending[2].resolve({positive:item(4),variants:[]});await third;assert.ok(states.at(-1).error);
const fourth=loader.load(3);loader.dispose();const count=states.length;pending[3].reject(Error('late'));await fourth;assert.equal(states.length,count);await loader.load(5);assert.equal(pending.length,4);
});
test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{
const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[];
const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose();
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}}));
});
test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{
const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24);
const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]);
});
test('negative variants append continuously without losing selected identity, and failures retry same cursor',async()=>{
const {createLibraryDetail}=await source;const calls=[];let count=0;const v=id=>({id,negative_text:id===9?'':'bad',explicit_empty:id===9});
const loader=createLibraryDetail({api:{get:async url=>{calls.push(url);count++;if(count===2)throw Error('offline');return {positive:item(1),variants:count===1?[v(9)]:[v(9),v(10)],variant_total:2,variants_has_more:count===1,variants_next_cursor:count===1?'next':null,unassigned_image_count:0};}},onChange:()=>{}});
await loader.load(1);assert.equal(loader.state.detail.variants.length,1);await loader.loadMore();assert.match(loader.state.variantsError.message,/offline/);assert.equal(loader.state.detail.variants[0].id,9);await loader.loadMore();assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[9,10]);assert.match(calls[1],/variant_cursor=next/);assert.equal(calls[1],calls[2]);assert.equal(await loader.loadMore(),0);loader.dispose();
});
-63
View File
@@ -1,63 +0,0 @@
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']);
});
test('positive library deep links preserve the selected identity without duplicate route renders', async () => {
const {resolveRoute,mountRouter}=await source;
assert.equal(resolveRoute('#/library/12').positiveId,12);
for(const hash of ['#/library/0','#/library/-1','#/library/1.5','#/library/9007199254740992','#/library/1/extra']) assert.equal(resolveRoute(hash).id,'images');
const win=new EventTarget();win.location={hash:'#/library/12'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
assert.equal(win.location.hash,'#/library/12');
win.dispatchEvent(new Event('hashchange'));assert.equal(seen.length,1);
win.location.hash='#/library/13';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
});
-142
View File
@@ -1,142 +0,0 @@
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();
});
+2 -1
View File
@@ -31,7 +31,8 @@ class APITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self._temp_db.name)
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
+8 -29
View File
@@ -70,7 +70,8 @@ class AdminAPITestCase(AioHTTPTestCase):
db_path = os.path.join(self.tmpdir, "prompts.db")
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI(db=PromptDatabase(db_path))
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(db_path)
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
@@ -475,13 +476,10 @@ class TestScansRunOffTheEventLoop(AdminAPITestCase):
).text()
self.assertIn('"added": 1', first)
self.assertIn('"unchanged": 1', second)
self.assertIn('"linked": 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", "bad"})
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0]["negative_text"], "bad")
self.assertEqual([p["text"] for p in prompts], ["a red square"])
async def test_output_scan_without_directories_reports_error(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.outside_dir)]
@@ -1558,7 +1556,7 @@ class TestOutputScanInternals(AdminAPITestCase):
"found",
)
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "linked")
def test_ingest_link_failures_are_tolerated(self):
f = Path("gen.png")
@@ -1591,23 +1589,7 @@ 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,
"enriched": 0,
"unchanged": 0,
"ambiguous": 0,
"unresolved": 0,
"conflict": 0,
"failed": 1,
"pairs_added": 0,
"pair_links_added": 0,
},
)
self.assertEqual(counts, {"processed": 2, "found": 0, "added": 0, "linked": 0})
async def test_scan_internal_error_streams_error_event(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
@@ -1655,10 +1637,7 @@ 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", "bad"})
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0]["negative_text"], "bad")
self.assertEqual([p["text"] for p in prompts], ["a red square"])
self.assertEqual(self.api._scan_job.last_event["type"], "complete")
self.assertFalse(self.api._scan_job.running)
@@ -1725,7 +1704,7 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
body = await (await self.client.request("POST", "/prompt_manager/scan")).text()
self.assertEqual(len(calls), 2)
self.assertIn('"unchanged": 1', body)
self.assertIn('"linked": 1', body)
async def test_failed_scan_is_not_left_running(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
+2 -1
View File
@@ -119,7 +119,8 @@ class AutotagAPITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
self.api = PromptManagerAPI()
self.api.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)
+2 -1
View File
@@ -45,7 +45,8 @@ class BackupRestoreTestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI(db=PromptDatabase(self.db_path))
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self.db_path)
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
+7 -28
View File
@@ -322,9 +322,7 @@ class TestLongJobLock(unittest.TestCase):
"""_acquire_long_job: one job per family at a time, released on exit."""
def setUp(self):
database = MagicMock()
database.cleanup_duplicates.return_value = 0
self.api = PromptManagerAPI(db=database)
self.api = PromptManagerAPI()
def _run(self, coro):
import asyncio
@@ -375,9 +373,8 @@ class CoreRoutesTestCase(AioHTTPTestCase):
self.tmpdir = tempfile.mkdtemp()
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI(
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
)
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
@@ -385,7 +382,6 @@ 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):
@@ -400,7 +396,6 @@ class CoreRoutesTestCase(AioHTTPTestCase):
UI_PATHS = (
"/prompt_manager/workspace",
"/prompt_manager/web",
"/prompt_manager/gallery.html",
"/prompt_manager/admin",
@@ -431,13 +426,7 @@ class TestUiRoutes(CoreRoutesTestCase):
async def test_unreadable_pages_are_500(self):
web_dir = self._fake_root()
for name in (
"workspace.html",
"index.html",
"metadata.html",
"admin.html",
"gallery.html",
):
for name in ("index.html", "metadata.html", "admin.html", "gallery.html"):
(web_dir / name).mkdir() # a directory cannot be read as a file
for path in UI_PATHS:
resp = await self.client.request("GET", path)
@@ -456,13 +445,6 @@ 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{}")
@@ -583,9 +565,8 @@ class TestInstanceHelpers(FolderPathsFixture):
def setUp(self):
super().setUp()
self.api = PromptManagerAPI(
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
)
self.api = PromptManagerAPI()
self.api.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)
@@ -687,9 +668,7 @@ class TestInstanceHelpers(FolderPathsFixture):
class TestMetadataParsing(unittest.TestCase):
def setUp(self):
database = MagicMock()
database.cleanup_duplicates.return_value = 0
self.api = PromptManagerAPI(db=database)
self.api = PromptManagerAPI()
self.tmpdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmpdir, True)
-71
View File
@@ -1,71 +0,0 @@
"""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()
+2 -1
View File
@@ -64,7 +64,8 @@ class ImageAPITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
self.api = PromptManagerAPI()
self.api.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)

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