Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3907fdc62 | ||
|
|
822e6a0607 | ||
|
|
ef1a4e944d | ||
|
|
62de2a3a3f | ||
|
|
bf96c1fdbb | ||
|
|
a12677c38c | ||
|
|
95fcdb3465 | ||
|
|
e3dcfcbf7b | ||
|
|
28eb8aaf4d | ||
|
|
f430266a5d | ||
|
|
decb69864a | ||
|
|
632bd81756 | ||
|
|
930f5c72d0 | ||
|
|
e94e5a08cf | ||
|
|
5104c84f05 | ||
|
|
2e49d1fadb | ||
|
|
752dff9098 | ||
|
|
cf77bbf21f | ||
|
|
a22290e253 | ||
|
|
62344be366 | ||
|
|
9b4f1ee085 | ||
|
|
53596eaeb5 | ||
|
|
9685952e4e | ||
|
|
6a8860d926 | ||
|
|
eafe35ce58 | ||
|
|
3870590704 | ||
|
|
a52ff4e052 | ||
|
|
035e816db6 | ||
|
|
f98f6fa08d | ||
|
|
ce82dfb912 | ||
|
|
5ded3189c1 |
@@ -5,16 +5,20 @@
|
||||
"py3.10 on ubuntu-latest",
|
||||
"py3.11 on ubuntu-latest",
|
||||
"py3.12 on ubuntu-latest",
|
||||
"py3.13 on ubuntu-latest",
|
||||
"py3.10 on windows-latest",
|
||||
"py3.11 on windows-latest",
|
||||
"py3.12 on windows-latest",
|
||||
"py3.13 on windows-latest",
|
||||
"py3.10 on macos-latest",
|
||||
"py3.11 on macos-latest",
|
||||
"py3.12 on macos-latest",
|
||||
"py3.13 on macos-latest",
|
||||
"coverage gate (85%)",
|
||||
"black and flake8",
|
||||
"frontend modules",
|
||||
"viewerjs"
|
||||
"viewerjs",
|
||||
"real ComfyUI compatibility"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -37,6 +37,62 @@ jobs:
|
||||
- name: Run tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
comfyui:
|
||||
name: real ComfyUI compatibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- name: Read the reviewed runtime pins
|
||||
id: runtime
|
||||
run: |
|
||||
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}`);
|
||||
JS
|
||||
- name: Check out pinned ComfyUI
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.runtime.outputs.repository }}
|
||||
ref: ${{ steps.runtime.outputs.revision }}
|
||||
path: .ci-comfyui
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ steps.runtime.outputs.pythonVersion }}
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements-dev.txt
|
||||
.ci-comfyui/requirements.txt
|
||||
tools/vendor/comfyui-runtime.json
|
||||
- name: Install CPU runtime and test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
- name: Classic and Nodes 2.0 startup and compatibility
|
||||
env:
|
||||
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
|
||||
PM_TEST_PYTHON: python
|
||||
run: npm run --prefix tools/vendor test:comfyui
|
||||
- name: Upload real-host reports and startup logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: real-comfyui-reports
|
||||
path: tools/vendor/test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
coverage:
|
||||
name: coverage gate (85%)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -78,6 +134,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Pure-JS frontend modules, tested with the runner's preinstalled Node
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Check shipped extension and page modules parse
|
||||
run: |
|
||||
shopt -s globstar nullglob
|
||||
for f in web/comfy/**/*.js web/comfy/**/*.mjs web/js/**/*.js web/js/**/*.mjs; do
|
||||
node --input-type=module --check < "$f" || exit 1
|
||||
done
|
||||
|
||||
- name: Run JS tests
|
||||
run: node --test tests/js/*.test.js
|
||||
|
||||
@@ -208,3 +208,6 @@ playwright-report/
|
||||
|
||||
# Combined frontend coverage reports
|
||||
coverage/
|
||||
|
||||
# Disposable upstream checkout for real-host CI
|
||||
.ci-comfyui/
|
||||
|
||||
@@ -142,6 +142,32 @@ _Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professiona
|
||||
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
|
||||
for reproducible updates, verification, and automated update PRs.
|
||||
|
||||
Prompt Manager appears as **Prompt Manager (Classic)** in the Add Node menu.
|
||||
It keeps the existing layout and behavior while using shared
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) controls
|
||||
that follow ComfyUI's light/dark theme. Existing workflows keep the same internal
|
||||
node identifier and sockets. The reusable architecture is inspired by
|
||||
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
|
||||
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
|
||||
|
||||
**Prompt Manager (Focused)** adds an editor-first layout with collapsible
|
||||
composition and metadata fields. Open **Browse library** to search saved prompts,
|
||||
filter by saved category and comma-separated tags, preview a selection, then choose
|
||||
**Use prompt**; **Load metadata** controls whether
|
||||
category and tags come with it. Metadata fields suggest saved categories and tags;
|
||||
choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the
|
||||
next tag; category remains a single value. New values can still be typed freely.
|
||||
Prepend and append STRING sockets remain visible
|
||||
when the fields are collapsed, so LoRA trigger outputs can connect directly.
|
||||
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
|
||||
support ComfyUI Classic and Nodes 2.0.
|
||||
|
||||
**Prompt Manager (Split)** keeps the same editor and library visible together.
|
||||
Wide nodes place the library beside the editor; narrow nodes stack the panels.
|
||||
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
|
||||
workflow persistence, and connectable prepend/append inputs. Encoding and storage
|
||||
match Classic and Focused.
|
||||
|
||||
### 🔬 ComfyUI Workflow Analysis
|
||||
|
||||
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
||||
|
||||
@@ -10,6 +10,7 @@ reviewed. Add a regression test for each bug; prove it fails before the fix.
|
||||
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
|
||||
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
|
||||
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
|
||||
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
|
||||
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
|
||||
|
||||
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
|
||||
@@ -44,11 +45,49 @@ or the port is occupied. Test servers are never reused. On unsupported Linux
|
||||
distributions, browser system dependencies may need manual installation; CI uses
|
||||
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
## Managed real ComfyUI
|
||||
|
||||
The shared engine comes from an exact commit of
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit), recorded
|
||||
in `tools/vendor/package.json` and its integrity-checked lockfile. The local
|
||||
adapter sets PromptManager's database/configuration and monitoring policy;
|
||||
source discovery, coverage mapping/gates and process lifecycle are shared code.
|
||||
KikoTools remains the reference and is unchanged by this integration.
|
||||
|
||||
Use a **dedicated disposable** ComfyUI checkout at the revision in
|
||||
`tools/vendor/comfyui-runtime.json`. With a separate Python environment, install
|
||||
that checkout's requirements, this repo's `requirements-dev.txt`, and the exact
|
||||
CPU torch/torchvision pins from the JSON (using PyTorch's CPU wheel index).
|
||||
Install Chromium using the Playwright command above, then run:
|
||||
|
||||
```sh
|
||||
PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
|
||||
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
|
||||
npm run --prefix tools/vendor test:comfyui
|
||||
```
|
||||
|
||||
CI performs this setup automatically. `PM_COMFY_URL` can select another
|
||||
`http://127.0.0.1:PORT/` address (default port 8189). This is an owned managed
|
||||
server: a running host is never reused. Do not point it at your live checkout.
|
||||
The launcher verifies the upstream SHA and clean tracked files, snapshots only
|
||||
tracked PromptManager files, and creates temporary user/input/output/database/
|
||||
configuration/log directories. Personal untracked files are excluded. It runs
|
||||
CPU/offline with only PromptManager enabled and removes owned state on shutdown.
|
||||
The upstream checkout must also be disposable: ComfyUI may initialize local
|
||||
runtime files there. Browser host-settings writes are intercepted; actual
|
||||
PromptManager routes use the disposable host and database.
|
||||
|
||||
`real-comfyui-reports` retains startup logs, version attachments, screenshots and
|
||||
traces for 14 days. Update upstream/frontend/CPU pins together after reviewing
|
||||
and running both startup projects; never replace the SHA with a moving branch.
|
||||
Update the toolkit via an exact reviewed commit archive and commit both npm
|
||||
manifest and lockfile. No toolkit or npm installation is needed by end users.
|
||||
|
||||
## Coverage and failures
|
||||
|
||||
Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges
|
||||
Node V8 unit coverage with Chromium page-workflow coverage through c8. Every
|
||||
`web/js/*.js` file appears, including untouched files at zero coverage. The
|
||||
`web/js/**/*.js` and `web/js/**/*.mjs` file appears, including untouched files at zero coverage. The
|
||||
controller tests provide V8 filenames so their executed code is counted too.
|
||||
|
||||
`tools/vendor/frontend-coverage-baseline.json` holds a floor for every file and
|
||||
@@ -64,14 +103,17 @@ The `viewerjs` check runs on every PR, including backend-only changes. GitHub
|
||||
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
|
||||
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
|
||||
|
||||
Live checks remain useful for ComfyUI host compatibility, real inference/GPU or
|
||||
model dependency changes, and visual design changes. The automated fixtures do
|
||||
not certify those environments or every existing product feature.
|
||||
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
|
||||
ways, actual database prompt selection, execution-text serialization, workflow
|
||||
reload, widget/style cleanup, node schemas and standalone-page opening. It does
|
||||
not queue inference or certify every existing feature, arbitrary host versions,
|
||||
GPU/model changes, cross-pack installation or visual design changes. Those still
|
||||
need targeted validation.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
`.github/branch-protection.json` records the required checks: all nine Python/OS
|
||||
combinations, Python coverage, formatting, frontend modules, and `viewerjs`.
|
||||
`.github/branch-protection.json` records the required checks: all twelve Python/OS
|
||||
combinations, Python coverage, formatting, frontend modules, `viewerjs`, and `real ComfyUI compatibility`.
|
||||
The branch must be current with main, and administrators follow the same checks.
|
||||
It does not require an additional human approval.
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc
|
||||
with persistent prompt storage, advanced search capabilities, and an automatic image
|
||||
gallery system.
|
||||
|
||||
This module provides two main node types:
|
||||
This module provides prompt node types:
|
||||
- PromptManager: CLIP encoding node that outputs CONDITIONING
|
||||
- PromptManagerFocused: Editor-first layout using the same encoding contract
|
||||
- PromptManagerSplit: Side-by-side editor and library using the same contract
|
||||
- PromptManagerText: Text-only node that outputs STRING with prepend/append
|
||||
functionality
|
||||
|
||||
Both nodes share the same database backend for persistent prompt storage and include
|
||||
The nodes share the same database backend for persistent prompt storage and include
|
||||
an automatic image gallery system that monitors ComfyUI output directories and links
|
||||
generated images to their source prompts.
|
||||
|
||||
@@ -38,18 +40,29 @@ def get_version():
|
||||
return "unknown"
|
||||
|
||||
|
||||
from .prompt_manager import PromptManager # noqa: E402
|
||||
from .prompt_manager import ( # noqa: E402
|
||||
PromptManager,
|
||||
PromptManagerFocused,
|
||||
PromptManagerSplit,
|
||||
)
|
||||
from .prompt_manager_pair import PromptManagerPair # noqa: E402
|
||||
from .prompt_manager_text import PromptManagerText # noqa: E402
|
||||
from .prompt_search_list import PromptSearchList # noqa: E402
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptManager": PromptManager,
|
||||
"PromptManagerFocused": PromptManagerFocused,
|
||||
"PromptManagerSplit": PromptManagerSplit,
|
||||
"PromptManagerPair": PromptManagerPair,
|
||||
"PromptManagerText": PromptManagerText,
|
||||
"PromptSearchList": PromptSearchList,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptManager": "Prompt Manager",
|
||||
"PromptManager": "Prompt Manager (Classic)",
|
||||
"PromptManagerFocused": "Prompt Manager (Focused)",
|
||||
"PromptManagerSplit": "Prompt Manager (Split)",
|
||||
"PromptManagerPair": "Prompt Manager (+/-)",
|
||||
"PromptManagerText": "Prompt Manager Text",
|
||||
"PromptSearchList": "Prompt Search List",
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ Database schema and models for KikoTextEncode prompt storage.
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import sqlite3
|
||||
import os
|
||||
import threading
|
||||
import tempfile
|
||||
import weakref
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Set, Tuple
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
@@ -29,6 +32,41 @@ except ImportError:
|
||||
ISO_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%f+00:00"
|
||||
|
||||
|
||||
MAX_PAIR_PROVENANCE_LENGTH = 4_000_000
|
||||
MAX_PAIR_PROVENANCE_DEPTH = 64
|
||||
|
||||
|
||||
def pair_provenance_error(encoded: object) -> Optional[str]:
|
||||
"""Validate the same bounded JSON object for live writes and restores."""
|
||||
if not isinstance(encoded, str):
|
||||
return "Pair provenance must be JSON text"
|
||||
if len(encoded) > MAX_PAIR_PROVENANCE_LENGTH:
|
||||
return "Pair provenance exceeds the size limit"
|
||||
try:
|
||||
value = json.loads(encoded)
|
||||
except (ValueError, TypeError, RecursionError):
|
||||
return "Pair provenance is not readable JSON"
|
||||
if not isinstance(value, dict):
|
||||
return "Pair provenance must be a JSON object"
|
||||
pending = [(iter((value,)), 0)]
|
||||
while pending:
|
||||
children, depth = pending[-1]
|
||||
try:
|
||||
item = next(children)
|
||||
except StopIteration:
|
||||
pending.pop()
|
||||
continue
|
||||
if depth > MAX_PAIR_PROVENANCE_DEPTH:
|
||||
return "Pair provenance exceeds the nesting limit"
|
||||
if isinstance(item, float) and not math.isfinite(item):
|
||||
return "Pair provenance contains nonfinite numbers"
|
||||
if isinstance(item, dict):
|
||||
pending.append((iter(item.values()), depth + 1))
|
||||
elif isinstance(item, list):
|
||||
pending.append((iter(item), depth + 1))
|
||||
return None
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""Current UTC time as ISO text with microseconds.
|
||||
|
||||
@@ -235,6 +273,224 @@ class PromptModel:
|
||||
|
||||
# Image uniqueness by full path instead of basename (3.2.4)
|
||||
self._migrate_image_uniqueness_by_path(conn)
|
||||
self._migrate_prompt_pairs(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_pairs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
positive_prompt_id INTEGER NOT NULL,
|
||||
negative_prompt_id INTEGER,
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
rating INTEGER CHECK(rating BETWEEN 1 AND 5),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)),
|
||||
FOREIGN KEY(positive_prompt_id)
|
||||
REFERENCES prompts(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY(negative_prompt_id)
|
||||
REFERENCES prompts(id) ON DELETE RESTRICT,
|
||||
UNIQUE(positive_prompt_id, negative_prompt_id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_pair_tags (
|
||||
pair_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY(pair_id, tag_id),
|
||||
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS image_prompt_pairs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
image_id INTEGER NOT NULL,
|
||||
pair_id INTEGER NOT NULL,
|
||||
provenance TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(image_id)
|
||||
REFERENCES generated_images(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE RESTRICT,
|
||||
UNIQUE(pair_id, image_id, provenance)
|
||||
)
|
||||
""")
|
||||
self._create_pair_indexes(conn)
|
||||
error = self._pair_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE prompt_pair_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO prompt_pair_schema")
|
||||
conn.execute("RELEASE prompt_pair_schema")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _create_pair_indexes(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_prompt_pairs_positive_only "
|
||||
"ON prompt_pairs(positive_prompt_id) WHERE negative_prompt_id IS NULL"
|
||||
)
|
||||
for name, target in (
|
||||
("idx_prompt_pairs_negative", "prompt_pairs(negative_prompt_id)"),
|
||||
("idx_prompt_pair_tags_tag", "prompt_pair_tags(tag_id)"),
|
||||
("idx_image_prompt_pairs_image", "image_prompt_pairs(image_id)"),
|
||||
):
|
||||
conn.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {target}")
|
||||
|
||||
@staticmethod
|
||||
def _pair_schema_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
required = {
|
||||
"prompt_pairs": {
|
||||
"id",
|
||||
"positive_prompt_id",
|
||||
"negative_prompt_id",
|
||||
"title",
|
||||
"category",
|
||||
"rating",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_used_at",
|
||||
"run_count",
|
||||
"archived",
|
||||
},
|
||||
"prompt_pair_tags": {"pair_id", "tag_id"},
|
||||
"image_prompt_pairs": {
|
||||
"id",
|
||||
"image_id",
|
||||
"pair_id",
|
||||
"provenance",
|
||||
"created_at",
|
||||
},
|
||||
}
|
||||
present = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if not present.intersection(required):
|
||||
return None
|
||||
if not set(required).issubset(present):
|
||||
return "Incomplete prompt pair schema"
|
||||
foreign_keys = {
|
||||
"prompt_pairs": {
|
||||
("positive_prompt_id", "prompts", "id", "RESTRICT"),
|
||||
("negative_prompt_id", "prompts", "id", "RESTRICT"),
|
||||
},
|
||||
"prompt_pair_tags": {
|
||||
("pair_id", "prompt_pairs", "id", "CASCADE"),
|
||||
("tag_id", "tags", "id", "CASCADE"),
|
||||
},
|
||||
"image_prompt_pairs": {
|
||||
("pair_id", "prompt_pairs", "id", "RESTRICT"),
|
||||
("image_id", "generated_images", "id", "CASCADE"),
|
||||
},
|
||||
}
|
||||
unique = {
|
||||
"prompt_pairs": ("positive_prompt_id", "negative_prompt_id"),
|
||||
"prompt_pair_tags": ("pair_id", "tag_id"),
|
||||
"image_prompt_pairs": ("pair_id", "image_id", "provenance"),
|
||||
}
|
||||
non_null = {
|
||||
"prompt_pairs": {
|
||||
"positive_prompt_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"run_count",
|
||||
"archived",
|
||||
},
|
||||
"prompt_pair_tags": {"pair_id", "tag_id"},
|
||||
"image_prompt_pairs": {"pair_id", "image_id", "provenance", "created_at"},
|
||||
}
|
||||
integer_columns = {
|
||||
"id",
|
||||
"positive_prompt_id",
|
||||
"negative_prompt_id",
|
||||
"pair_id",
|
||||
"tag_id",
|
||||
"image_id",
|
||||
"rating",
|
||||
"run_count",
|
||||
"archived",
|
||||
}
|
||||
for table, columns in required.items():
|
||||
info = {row[1]: row for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
if not columns.issubset(info):
|
||||
return f"{table} is missing required pair columns"
|
||||
if any(not info[name][3] for name in non_null[table]):
|
||||
return f"{table} has nullable required pair columns"
|
||||
if any(
|
||||
info[name][2].upper()
|
||||
!= ("INTEGER" if name in integer_columns else "TEXT")
|
||||
for name in columns
|
||||
):
|
||||
return f"{table} has invalid pair column types"
|
||||
primary = tuple(
|
||||
name
|
||||
for name in sorted(info, key=lambda name: info[name][5])
|
||||
if info[name][5]
|
||||
)
|
||||
if primary != (
|
||||
("pair_id", "tag_id") if table == "prompt_pair_tags" else ("id",)
|
||||
):
|
||||
return f"{table} has an invalid pair primary key"
|
||||
actual_fks = {
|
||||
(row[3], row[2], row[4], row[6])
|
||||
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
|
||||
}
|
||||
if actual_fks != foreign_keys[table]:
|
||||
return f"{table} has invalid pair foreign key definitions"
|
||||
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
|
||||
return f"{table} contains foreign key violations"
|
||||
indexes = list(conn.execute(f"PRAGMA index_list({table})"))
|
||||
index_columns = {}
|
||||
for row in indexes:
|
||||
name = row[1].replace('"', '""')
|
||||
index_columns[row[1]] = tuple(
|
||||
item[2] for item in conn.execute(f'PRAGMA index_info("{name}")')
|
||||
)
|
||||
if not any(
|
||||
row[2] and not row[4] and index_columns[row[1]] == unique[table]
|
||||
for row in indexes
|
||||
):
|
||||
return f"{table} is missing pair uniqueness constraints"
|
||||
if table == "prompt_pairs":
|
||||
null_unique = False
|
||||
for row in indexes:
|
||||
if (
|
||||
row[2]
|
||||
and row[4]
|
||||
and index_columns[row[1]] == ("positive_prompt_id",)
|
||||
):
|
||||
sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='index' AND name"
|
||||
"=?",
|
||||
(row[1],),
|
||||
).fetchone()[0]
|
||||
if " ".join(sql.lower().split()).endswith(
|
||||
"where negative_prompt_id is null"
|
||||
):
|
||||
null_unique = True
|
||||
if not null_unique:
|
||||
return "prompt_pairs is missing empty-negative pair uniqueness"
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs ip "
|
||||
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
|
||||
"JOIN generated_images gi ON gi.id=ip.image_id "
|
||||
"WHERE pp.positive_prompt_id IS NOT gi.prompt_id LIMIT 1"
|
||||
).fetchone():
|
||||
return "Image pair association has a different positive prompt owner"
|
||||
for row in conn.execute("SELECT provenance FROM image_prompt_pairs"):
|
||||
error = pair_provenance_error(row[0])
|
||||
if error:
|
||||
return error
|
||||
return None
|
||||
|
||||
def _create_indexes(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
@@ -772,6 +1028,15 @@ class PromptModel:
|
||||
False,
|
||||
"prompts table is missing required columns: " + ", ".join(missing),
|
||||
)
|
||||
forbidden = conn.execute(
|
||||
"SELECT type FROM sqlite_master WHERE type IN ('trigger', 'view') "
|
||||
"LIMIT 1"
|
||||
).fetchone()
|
||||
if forbidden:
|
||||
return False, f"Database contains an unsupported {forbidden[0]}"
|
||||
pair_error = PromptModel._pair_schema_error(conn)
|
||||
if pair_error:
|
||||
return False, pair_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
@@ -814,13 +1079,11 @@ class PromptModel:
|
||||
"""
|
||||
Replace the live database with the file at src_path.
|
||||
|
||||
The current database is first backed up next to itself as
|
||||
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), the
|
||||
pages of ``src_path`` are copied into the live database through
|
||||
SQLite's backup API (visible to every open connection) and the schema
|
||||
migrations run on the restored content.
|
||||
|
||||
Callers should run verify_database_file(src_path) first.
|
||||
A disposable SQLite backup of the source is migrated and verified first.
|
||||
Only then is the current database backed up next to itself as
|
||||
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), and the
|
||||
staged pages replace the live pages through SQLite's backup API.
|
||||
Migration failures never replace live content or modify the source.
|
||||
|
||||
Args:
|
||||
src_path: SQLite file to restore
|
||||
@@ -833,17 +1096,42 @@ class PromptModel:
|
||||
RuntimeError: If the previous database could not be backed up
|
||||
OSError: If the restored file could not be copied into place
|
||||
"""
|
||||
backup_path = ""
|
||||
if os.path.exists(self.db_path):
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = f"{self.db_path}.backup_{timestamp}"
|
||||
if not self.backup_database(backup_path):
|
||||
raise RuntimeError("Could not back up the current database")
|
||||
self.logger.info(f"Current database backed up to: {backup_path}")
|
||||
|
||||
self._replace_contents_from(src_path)
|
||||
with PromptModel._init_lock:
|
||||
PromptModel._initialized_paths.discard(self._schema_key())
|
||||
self._ensure_database_exists()
|
||||
self.logger.info(f"Database restored from {src_path}")
|
||||
return backup_path
|
||||
ok, reason = self.verify_database_file(src_path)
|
||||
if not ok:
|
||||
raise ValueError(reason)
|
||||
with tempfile.TemporaryDirectory(prefix="prompt-manager-restore-") as directory:
|
||||
staged_path = str(Path(directory) / "staged.db")
|
||||
source = sqlite3.connect(
|
||||
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
|
||||
)
|
||||
try:
|
||||
with closing(sqlite3.connect(staged_path)) as destination:
|
||||
source.backup(destination)
|
||||
finally:
|
||||
source.close()
|
||||
staged = None
|
||||
try:
|
||||
staged = PromptModel(staged_path)
|
||||
ok, reason = self.verify_database_file(staged_path)
|
||||
if not ok:
|
||||
raise ValueError(reason)
|
||||
staged.close_all()
|
||||
backup_path = ""
|
||||
if os.path.exists(self.db_path):
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = f"{self.db_path}.backup_{timestamp}"
|
||||
if not self.backup_database(backup_path):
|
||||
raise RuntimeError("Could not back up the current database")
|
||||
self.logger.info(f"Current database backed up to: {backup_path}")
|
||||
self._replace_contents_from(staged_path)
|
||||
with PromptModel._init_lock:
|
||||
PromptModel._initialized_paths.add(self._schema_key())
|
||||
self.logger.info(f"Database restored from {src_path}")
|
||||
return backup_path
|
||||
finally:
|
||||
if staged is not None:
|
||||
staged.close_all()
|
||||
with PromptModel._init_lock:
|
||||
PromptModel._initialized_paths.discard(
|
||||
os.path.normcase(os.path.realpath(staged_path))
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
@@ -146,7 +147,7 @@ def _coerce_file_info(metadata: Any) -> tuple:
|
||||
)
|
||||
|
||||
|
||||
class PromptDatabase:
|
||||
class PromptDatabase(PairOperations):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
@@ -581,7 +582,8 @@ class PromptDatabase:
|
||||
Returns:
|
||||
bool: True if deletion was successful, False otherwise
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
self._ensure_prompts_unpaired(conn, [prompt_id])
|
||||
# First delete related images to avoid foreign key constraint
|
||||
conn.execute(
|
||||
"DELETE FROM generated_images WHERE prompt_id = ?", (prompt_id,)
|
||||
@@ -597,7 +599,7 @@ class PromptDatabase:
|
||||
Returns:
|
||||
Number of prompts deleted.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
# Get IDs first for cascade cleanup
|
||||
ids = [
|
||||
r[0]
|
||||
@@ -607,6 +609,7 @@ class PromptDatabase:
|
||||
]
|
||||
if not ids:
|
||||
return 0
|
||||
self._ensure_prompts_unpaired(conn, ids)
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
conn.execute(
|
||||
f"DELETE FROM generated_images WHERE prompt_id IN ({placeholders})",
|
||||
@@ -632,7 +635,9 @@ class PromptDatabase:
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT DISTINCT TRIM(category) as category FROM prompts WHERE "
|
||||
"SELECT TRIM(category) AS category FROM prompts WHERE "
|
||||
"category IS NOT NULL AND TRIM(category) != '' UNION "
|
||||
"SELECT TRIM(category) AS category FROM prompt_pairs WHERE "
|
||||
"category IS NOT NULL AND TRIM(category) != '' ORDER BY category"
|
||||
)
|
||||
return [row["category"] for row in cursor.fetchall()]
|
||||
@@ -699,16 +704,16 @@ class PromptDatabase:
|
||||
|
||||
def get_all_tags(self) -> List[str]:
|
||||
"""
|
||||
Get all unique tags that are in use (linked to at least one prompt).
|
||||
Get all unique tags linked to a standalone prompt or reusable pair.
|
||||
|
||||
Returns:
|
||||
Sorted list of tag names
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT DISTINCT t.name AS tag "
|
||||
"FROM prompt_tags pt "
|
||||
"JOIN tags t ON pt.tag_id = t.id "
|
||||
"SELECT t.name AS tag FROM tags t WHERE "
|
||||
"EXISTS(SELECT 1 FROM prompt_tags pt WHERE pt.tag_id=t.id) OR "
|
||||
"EXISTS(SELECT 1 FROM prompt_pair_tags pp WHERE pp.tag_id=t.id) "
|
||||
"ORDER BY t.name"
|
||||
)
|
||||
return [row["tag"] for row in cursor.fetchall()]
|
||||
@@ -784,7 +789,8 @@ class PromptDatabase:
|
||||
|
||||
Returns tags whose name starts with ``prefix`` (case-insensitive),
|
||||
counted over the prompts that carry every tag in ``with_tags``; the
|
||||
context tags themselves are left out. Ordered by count, then name.
|
||||
context tags themselves are left out. Counts include separate standalone
|
||||
and pair references, with owner types kept distinct. Ordered by count/name.
|
||||
"""
|
||||
seen = set()
|
||||
context = []
|
||||
@@ -799,11 +805,11 @@ class PromptDatabase:
|
||||
if context:
|
||||
placeholders = ",".join("?" * len(context))
|
||||
where.append(
|
||||
"pt.prompt_id IN ("
|
||||
" SELECT pt2.prompt_id FROM prompt_tags pt2"
|
||||
"(pt.kind,pt.owner_id) IN ("
|
||||
" SELECT pt2.kind,pt2.owner_id FROM tag_refs pt2"
|
||||
" JOIN tags t2 ON t2.id = pt2.tag_id"
|
||||
f" WHERE t2.name COLLATE NOCASE IN ({placeholders})"
|
||||
" GROUP BY pt2.prompt_id"
|
||||
" GROUP BY pt2.kind,pt2.owner_id"
|
||||
" HAVING COUNT(DISTINCT lower(t2.name)) = ?)"
|
||||
)
|
||||
params.extend(context)
|
||||
@@ -813,8 +819,11 @@ class PromptDatabase:
|
||||
params.append(max(1, int(limit)))
|
||||
|
||||
sql = (
|
||||
"WITH tag_refs(kind,owner_id,tag_id) AS ("
|
||||
"SELECT 'prompt',prompt_id,tag_id FROM prompt_tags UNION ALL "
|
||||
"SELECT 'pair',pair_id,tag_id FROM prompt_pair_tags) "
|
||||
"SELECT t.name AS name, COUNT(*) AS count"
|
||||
" FROM prompt_tags pt JOIN tags t ON t.id = pt.tag_id"
|
||||
" FROM tag_refs pt JOIN tags t ON t.id = pt.tag_id"
|
||||
f" WHERE {' AND '.join(where)}"
|
||||
" GROUP BY t.id"
|
||||
" ORDER BY count DESC, t.name COLLATE NOCASE ASC"
|
||||
@@ -921,6 +930,8 @@ class PromptDatabase:
|
||||
|
||||
old_name = old_name.strip()
|
||||
new_name = new_name.strip()
|
||||
if old_name == new_name:
|
||||
return {"success": True, "affected_count": 0, "skipped_count": 0}
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute("SELECT id FROM tags WHERE name = ?", (old_name,))
|
||||
@@ -942,12 +953,14 @@ class PromptDatabase:
|
||||
if existing_new:
|
||||
# Target tag exists — merge: move links, handle conflicts, delete old
|
||||
new_tag_id = existing_new["id"]
|
||||
conn.execute(
|
||||
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
|
||||
(new_tag_id, old_tag_id),
|
||||
)
|
||||
# Delete remaining links (conflicts = prompt already had the target tag)
|
||||
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (old_tag_id,))
|
||||
for junction in ("prompt_tags", "prompt_pair_tags"):
|
||||
conn.execute(
|
||||
f"UPDATE OR IGNORE {junction} SET tag_id = ? WHERE tag_id = ?",
|
||||
(new_tag_id, old_tag_id),
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM {junction} WHERE tag_id = ?", (old_tag_id,)
|
||||
)
|
||||
conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,))
|
||||
else:
|
||||
# Simple rename
|
||||
@@ -966,7 +979,7 @@ class PromptDatabase:
|
||||
"""
|
||||
Remove a tag from all prompts that use it.
|
||||
|
||||
With junction tables, deleting from the tags table cascades to prompt_tags.
|
||||
Deleting from tags cascades to standalone and pair tag relationships.
|
||||
|
||||
Args:
|
||||
tag_name: Tag name to remove
|
||||
@@ -1033,20 +1046,27 @@ class PromptDatabase:
|
||||
if not src_row:
|
||||
continue
|
||||
src_id = src_row["id"]
|
||||
if src_id == target_id:
|
||||
continue
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM prompt_tags WHERE tag_id = ?", (src_id,)
|
||||
)
|
||||
src_count = cursor.fetchone()["c"]
|
||||
|
||||
if src_count > 0:
|
||||
# Move links from source to target (ignore conflicts)
|
||||
conn.execute(
|
||||
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
|
||||
(target_id, src_id),
|
||||
)
|
||||
# Delete remaining (conflicts = prompt already had target tag)
|
||||
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (src_id,))
|
||||
pair_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompt_pair_tags WHERE tag_id = ?", (src_id,)
|
||||
).fetchone()[0]
|
||||
if src_count or pair_count:
|
||||
for junction in ("prompt_tags", "prompt_pair_tags"):
|
||||
conn.execute(
|
||||
f"UPDATE OR IGNORE {junction} "
|
||||
"SET tag_id = ? WHERE tag_id = ?",
|
||||
(target_id, src_id),
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM {junction} WHERE tag_id = ?", (src_id,)
|
||||
)
|
||||
tags_merged += 1
|
||||
affected += src_count
|
||||
|
||||
@@ -1290,7 +1310,7 @@ class PromptDatabase:
|
||||
"""
|
||||
self.logger.info("Starting duplicate cleanup process with image preservation")
|
||||
try:
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
# Find duplicates by text content (case-insensitive)
|
||||
# Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility
|
||||
# We'll sort the IDs manually after fetching
|
||||
@@ -1319,6 +1339,11 @@ class PromptDatabase:
|
||||
id_date_pairs.sort(key=lambda x: x[1]) # Sort by date
|
||||
sorted_ids = [int(pair[0]) for pair in id_date_pairs]
|
||||
|
||||
try:
|
||||
self._ensure_prompts_unpaired(conn, sorted_ids)
|
||||
except PairDependencyError:
|
||||
continue
|
||||
|
||||
# Keep the oldest one (first), merge and delete the rest
|
||||
primary_id = sorted_ids[0] # Keep the oldest
|
||||
duplicate_ids = sorted_ids[1:]
|
||||
@@ -1864,7 +1889,8 @@ class PromptDatabase:
|
||||
Raises:
|
||||
sqlite3.IntegrityError: Another prompt already has the new hash.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
self._ensure_prompts_unpaired(conn, [prompt_id])
|
||||
cursor = conn.execute(
|
||||
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
|
||||
(
|
||||
@@ -1912,7 +1938,8 @@ class PromptDatabase:
|
||||
Number of prompts actually deleted.
|
||||
"""
|
||||
count = 0
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
self._ensure_prompts_unpaired(conn, prompt_ids)
|
||||
for pid in prompt_ids:
|
||||
conn.execute("DELETE FROM generated_images WHERE prompt_id = ?", (pid,))
|
||||
cursor = conn.execute("DELETE FROM prompts WHERE id = ?", (pid,))
|
||||
@@ -1985,7 +2012,7 @@ class PromptDatabase:
|
||||
cursor = conn.execute(
|
||||
"""SELECT gi.prompt_id, p.text, p.category, p.rating, p.notes,
|
||||
gi.workflow_data, gi.prompt_metadata, gi.generation_time,
|
||||
gi.parameters
|
||||
gi.parameters, gi.id, gi.image_path, gi.file_path
|
||||
FROM generated_images gi
|
||||
JOIN prompts p ON gi.prompt_id = p.id
|
||||
WHERE gi.image_path = ? OR gi.file_path = ?
|
||||
@@ -1997,9 +2024,71 @@ class PromptDatabase:
|
||||
f"%{escape_like(os.path.basename(image_path))}",
|
||||
),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
rows = cursor.fetchall()
|
||||
exact = [
|
||||
row
|
||||
for row in rows
|
||||
if row[10] == image_path or row[11] == normalize_image_path(image_path)
|
||||
]
|
||||
matches = exact or rows
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
row = matches[0]
|
||||
associations = self.get_image_prompt_pairs(row[9])
|
||||
pair_ids = {item["pair_id"] for item in associations}
|
||||
pair = (
|
||||
self.get_prompt_pair(next(iter(pair_ids)))
|
||||
if len(pair_ids) == 1
|
||||
else None
|
||||
)
|
||||
candidates = []
|
||||
for item in associations:
|
||||
evidence = item["provenance"]
|
||||
positive_side = evidence.get("positive")
|
||||
negative_side = evidence.get("negative")
|
||||
if not isinstance(positive_side, dict) or not isinstance(
|
||||
negative_side, dict
|
||||
):
|
||||
continue
|
||||
positive, negative = positive_side.get("text"), negative_side.get(
|
||||
"text"
|
||||
)
|
||||
if not isinstance(positive, str) or not isinstance(negative, str):
|
||||
continue
|
||||
sources = []
|
||||
for side in (positive_side, negative_side):
|
||||
provenance = side.get("provenance")
|
||||
sources.append(
|
||||
provenance.get("text_source")
|
||||
if isinstance(provenance, dict)
|
||||
else None
|
||||
)
|
||||
score = sources.count("execution")
|
||||
source = (
|
||||
"execution"
|
||||
if score == 2
|
||||
else (
|
||||
"mixed"
|
||||
if score
|
||||
else (
|
||||
"embedded-parameters"
|
||||
if sources == ["embedded-parameters"] * 2
|
||||
else "graph-inferred"
|
||||
)
|
||||
)
|
||||
)
|
||||
candidates.append((score, positive, negative, source))
|
||||
context = None
|
||||
if candidates and pair:
|
||||
best_score = max(item[0] for item in candidates)
|
||||
contexts = {item[1:] for item in candidates if item[0] == best_score}
|
||||
if len(contexts) == 1:
|
||||
positive, negative, source = next(iter(contexts))
|
||||
context = {
|
||||
"positive_text": positive,
|
||||
"negative_text": negative,
|
||||
"text_source": source,
|
||||
}
|
||||
tags = self._get_prompt_tags(conn, row[0])
|
||||
try:
|
||||
workflow = json.loads(row[5]) if row[5] else None
|
||||
@@ -2010,6 +2099,8 @@ class PromptDatabase:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
prompt_meta = None
|
||||
return {
|
||||
"pair": pair,
|
||||
"pair_context": context,
|
||||
"prompt_id": row[0],
|
||||
"text": row[1],
|
||||
"category": row[2],
|
||||
@@ -2050,11 +2141,15 @@ class PromptDatabase:
|
||||
|
||||
Returns number of prompts removed.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT p.id FROM prompts p
|
||||
LEFT JOIN generated_images gi ON p.id = gi.prompt_id
|
||||
WHERE gi.prompt_id IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM prompt_pairs pp
|
||||
WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM prompt_tags pt
|
||||
JOIN tags t ON pt.tag_id = t.id
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from .models import normalize_image_path
|
||||
from .operations import _coerce_file_info
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
state = resolution.get("state", "unresolved")
|
||||
if state != "resolved":
|
||||
return {"status": state, "pairs_added": 0, "links_added": 0}
|
||||
positive, negative = resolution["positive"], resolution["negative"]
|
||||
positive_text = positive.get("storage_text", positive["text"])
|
||||
negative_text = negative.get("storage_text", negative["text"])
|
||||
normalized = normalize_image_path(image_path)
|
||||
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
with db._pair_transaction() as conn:
|
||||
images = conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalized, image_path),
|
||||
).fetchall()
|
||||
if len(images) > 1 or (
|
||||
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
|
||||
):
|
||||
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
|
||||
existing = images[0] if images else None
|
||||
if existing:
|
||||
prior = conn.execute(
|
||||
"SELECT n.hash AS negative_hash FROM image_prompt_pairs ip "
|
||||
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE ip.image_id=?",
|
||||
(existing["id"],),
|
||||
).fetchall()
|
||||
expected = (
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None
|
||||
)
|
||||
if any(row["negative_hash"] != expected for row in prior):
|
||||
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
|
||||
prior_pair = conn.execute(
|
||||
"SELECT pp.id FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
||||
"WHERE p.hash=? AND n.hash IS ?",
|
||||
(
|
||||
generate_prompt_hash(positive_text),
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None,
|
||||
),
|
||||
).fetchone()
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
pair = conn.execute(
|
||||
"SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone()
|
||||
pairs_added = int(prior_pair is None)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
snapshots = {
|
||||
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
|
||||
for column, key in (
|
||||
("workflow_data", "workflow"),
|
||||
("prompt_metadata", "prompt"),
|
||||
("parameters", "parameters"),
|
||||
)
|
||||
if key in metadata
|
||||
}
|
||||
info = {
|
||||
"file_size": file_size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"format": image_format,
|
||||
}
|
||||
snapshots.update(
|
||||
{key: value for key, value in info.items() if value is not None}
|
||||
)
|
||||
changed = False
|
||||
if existing:
|
||||
image_id = existing["id"]
|
||||
updates = {
|
||||
key: value for key, value in snapshots.items() if existing[key] != value
|
||||
}
|
||||
if updates:
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET "
|
||||
+ ",".join(f"{key}=?" for key in updates)
|
||||
+ " WHERE id=?",
|
||||
[*updates.values(), image_id],
|
||||
)
|
||||
changed = True
|
||||
else:
|
||||
columns = ["prompt_id", "image_path", "filename", "file_path", *snapshots]
|
||||
values = [
|
||||
pair["positive_prompt_id"],
|
||||
image_path,
|
||||
os.path.basename(image_path),
|
||||
normalized,
|
||||
*snapshots.values(),
|
||||
]
|
||||
image_id = conn.execute(
|
||||
"INSERT INTO generated_images("
|
||||
+ ",".join(columns)
|
||||
+ ") VALUES("
|
||||
+ ",".join("?" for _ in columns)
|
||||
+ ")",
|
||||
values,
|
||||
).lastrowid
|
||||
linked = conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? "
|
||||
"AND image_id=? AND provenance=?",
|
||||
(pair_id, image_id, encoded),
|
||||
).fetchone()
|
||||
db._link_image_to_pair(conn, pair_id, image_id, provenance)
|
||||
status = (
|
||||
"added"
|
||||
if existing is None
|
||||
else "enriched" if changed or not linked else "unchanged"
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"pairs_added": pairs_added,
|
||||
"links_added": int(not linked),
|
||||
"pair_id": pair_id,
|
||||
"image_id": image_id,
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Ordered reusable prompt pairs and their generation provenance."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .models import pair_provenance_error, utc_now_iso
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class PairDependencyError(ValueError):
|
||||
"""An operation would change or remove content used by a saved pair."""
|
||||
|
||||
|
||||
def _validate_metadata(
|
||||
title: Optional[str],
|
||||
category: Optional[str],
|
||||
tags: Optional[List[str]],
|
||||
rating: Optional[int],
|
||||
notes: Optional[str],
|
||||
) -> Optional[List[str]]:
|
||||
for name, value in (("title", title), ("category", category), ("notes", notes)):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError(f"{name} must be a string")
|
||||
if rating is not None and (type(rating) is not int or not 1 <= rating <= 5):
|
||||
raise ValueError("Rating must be between 1 and 5")
|
||||
if tags is None:
|
||||
return None
|
||||
if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags):
|
||||
raise ValueError("tags must be a list of strings")
|
||||
return list(dict.fromkeys(tag.strip() for tag in tags if tag.strip()))
|
||||
|
||||
|
||||
class PairOperations:
|
||||
"""Connection-level pair operations mixed into PromptDatabase."""
|
||||
|
||||
@contextmanager
|
||||
def _pair_transaction(self) -> Iterator[sqlite3.Connection]:
|
||||
conn = self.model.get_connection()
|
||||
if conn.in_transaction:
|
||||
raise RuntimeError("Pair operations require their own transaction")
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
yield conn
|
||||
|
||||
def _ensure_prompts_unpaired(
|
||||
self, conn: sqlite3.Connection, prompt_ids: List[int]
|
||||
) -> None:
|
||||
if not prompt_ids:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in prompt_ids)
|
||||
row = conn.execute(
|
||||
f"SELECT id FROM prompt_pairs WHERE positive_prompt_id IN ({placeholders}) "
|
||||
f"OR negative_prompt_id IN ({placeholders}) LIMIT 1",
|
||||
[*prompt_ids, *prompt_ids],
|
||||
).fetchone()
|
||||
if row:
|
||||
raise PairDependencyError(
|
||||
f"Prompt belongs to saved pair {row['id']}; delete the pair first "
|
||||
"or save changed text as a new prompt"
|
||||
)
|
||||
|
||||
def _pair_component(self, conn: sqlite3.Connection, text: str) -> int:
|
||||
prompt_hash = generate_prompt_hash(text)
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(text, hash, created_at, updated_at) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(hash) DO NOTHING",
|
||||
(text.strip(), prompt_hash, utc_now_iso(), utc_now_iso()),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM prompts WHERE hash = ?", (prompt_hash,)
|
||||
).fetchone()["id"]
|
||||
|
||||
def _sync_pair_tags(
|
||||
self, conn: sqlite3.Connection, pair_id: int, tags: List[str]
|
||||
) -> None:
|
||||
conn.execute("DELETE FROM prompt_pair_tags WHERE pair_id = ?", (pair_id,))
|
||||
for tag_id in self._ensure_tags(conn, tags).values():
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pair_tags(pair_id,tag_id) VALUES(?,?)",
|
||||
(pair_id, tag_id),
|
||||
)
|
||||
|
||||
def save_prompt_pair(
|
||||
self,
|
||||
positive_text: str,
|
||||
negative_text: str = "",
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
with self._pair_transaction() as conn:
|
||||
return self._save_pair(
|
||||
conn,
|
||||
positive_text,
|
||||
negative_text,
|
||||
title=title,
|
||||
category=category,
|
||||
tags=tags,
|
||||
rating=rating,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def _save_pair(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
positive_text: str,
|
||||
negative_text: str = "",
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
if not isinstance(positive_text, str) or not positive_text.strip():
|
||||
raise ValueError("Positive prompt text cannot be empty")
|
||||
if not isinstance(negative_text, str):
|
||||
raise ValueError("Negative prompt text must be a string; use '' for empty")
|
||||
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||
positive = self._pair_component(conn, positive_text)
|
||||
negative = (
|
||||
self._pair_component(conn, negative_text) if negative_text.strip() else None
|
||||
)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_pairs WHERE positive_prompt_id = ? "
|
||||
"AND negative_prompt_id IS ?",
|
||||
(positive, negative),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
now = utc_now_iso()
|
||||
pair_id = conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id,title,"
|
||||
"category,rating,notes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
|
||||
(positive, negative, title, category, rating, notes, now, now),
|
||||
).lastrowid
|
||||
if tags:
|
||||
self._sync_pair_tags(conn, pair_id, tags)
|
||||
return pair_id
|
||||
|
||||
def _read_pair_rows(
|
||||
self, conn: sqlite3.Connection, where: str, params: List[Any], suffix: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"SELECT pp.*, p.text AS positive_text, "
|
||||
"COALESCE(n.text,'') AS negative_text,"
|
||||
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
|
||||
"WHERE ip.pair_id=pp.id) AS image_count FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where + suffix,
|
||||
params,
|
||||
).fetchall()
|
||||
pairs = [dict(row) for row in rows]
|
||||
if not pairs:
|
||||
return pairs
|
||||
by_id = {pair["id"]: pair for pair in pairs}
|
||||
for pair in pairs:
|
||||
pair["archived"] = bool(pair["archived"])
|
||||
pair["tags"] = []
|
||||
placeholders = ",".join("?" for _ in by_id)
|
||||
for row in conn.execute(
|
||||
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt JOIN tags t ON "
|
||||
f"t.id=pt.tag_id WHERE pt.pair_id IN ({placeholders}) ORDER BY t.name",
|
||||
list(by_id),
|
||||
):
|
||||
by_id[row["pair_id"]]["tags"].append(row["name"])
|
||||
return pairs
|
||||
|
||||
def get_prompt_pair(self, pair_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Return components, pair metadata and image count, including archives."""
|
||||
rows = self._read_pair_rows(self.model.get_connection(), "pp.id = ?", [pair_id])
|
||||
return rows[0] if rows else None
|
||||
|
||||
def list_prompt_pairs(
|
||||
self,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
include_archived: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
sort: str = "newest",
|
||||
) -> Dict[str, Any]:
|
||||
"""List pairs with all-tag filtering and stable paginated ordering."""
|
||||
orders = {
|
||||
"newest": "pp.id DESC",
|
||||
"last_used_desc": "julianday(pp.last_used_at) DESC, pp.id DESC",
|
||||
}
|
||||
if sort not in orders:
|
||||
raise ValueError("Unknown pair sort order")
|
||||
if type(limit) is not int or not 1 <= limit <= 500:
|
||||
raise ValueError("limit must be between 1 and 500")
|
||||
if type(offset) is not int or offset < 0:
|
||||
raise ValueError("offset must be a nonnegative integer")
|
||||
tags = _validate_metadata(None, category, tags, None, None)
|
||||
if text is not None and not isinstance(text, str):
|
||||
raise ValueError("text must be a string")
|
||||
clauses, params = ["1=1"], []
|
||||
if not include_archived:
|
||||
clauses.append("pp.archived=0")
|
||||
if category is not None:
|
||||
clauses.append("pp.category = ?")
|
||||
params.append(category)
|
||||
if text:
|
||||
clauses.append("(p.text LIKE ? OR n.text LIKE ? OR pp.title LIKE ?)")
|
||||
params.extend([f"%{text}%"] * 3)
|
||||
for tag in tags or []:
|
||||
clauses.append(
|
||||
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t "
|
||||
"ON t.id=pt.tag_id WHERE pt.pair_id=pp.id AND t.name=?)"
|
||||
)
|
||||
params.append(tag)
|
||||
where = " AND ".join(clauses)
|
||||
conn = self.model.get_connection()
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where,
|
||||
params,
|
||||
).fetchone()[0]
|
||||
pairs = self._read_pair_rows(
|
||||
conn,
|
||||
where,
|
||||
[*params, limit, offset],
|
||||
f" ORDER BY {orders[sort]} LIMIT ? OFFSET ?",
|
||||
)
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": offset + len(pairs) < total,
|
||||
}
|
||||
|
||||
def update_prompt_pair_metadata(
|
||||
self,
|
||||
pair_id: int,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Update pair metadata explicitly; None preserves and empty values clear."""
|
||||
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||
changes = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("title", title),
|
||||
("category", category),
|
||||
("rating", rating),
|
||||
("notes", notes),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
if not changes and tags is None:
|
||||
return False
|
||||
with self._pair_transaction() as conn:
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone():
|
||||
return False
|
||||
changes["updated_at"] = utc_now_iso()
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET "
|
||||
+ ",".join(f"{key}=?" for key in changes)
|
||||
+ " WHERE id=?",
|
||||
[*changes.values(), pair_id],
|
||||
)
|
||||
if tags is not None:
|
||||
self._sync_pair_tags(conn, pair_id, tags)
|
||||
return True
|
||||
|
||||
def archive_prompt_pair(self, pair_id: int, archived: bool = True) -> bool:
|
||||
"""Hide a reusable pair without changing components or generation history."""
|
||||
if type(archived) is not bool:
|
||||
raise ValueError("archived must be a boolean")
|
||||
with self._pair_transaction() as conn:
|
||||
return (
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET archived=?,updated_at=? WHERE id=?",
|
||||
(int(archived), utc_now_iso(), pair_id),
|
||||
).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def delete_prompt_pair(self, pair_id: int) -> bool:
|
||||
"""Delete unused pair metadata; generated pairs must be archived instead."""
|
||||
with self._pair_transaction() as conn:
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? LIMIT 1", (pair_id,)
|
||||
).fetchone():
|
||||
raise PairDependencyError(
|
||||
"Pair has generated images; archive it instead"
|
||||
)
|
||||
return (
|
||||
conn.execute("DELETE FROM prompt_pairs WHERE id=?", (pair_id,)).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def record_prompt_pair_use(self, pair_id: int, times: int = 1) -> bool:
|
||||
"""Count pair uses independently of standalone prompt usage."""
|
||||
if type(times) is not int or times < 1:
|
||||
raise ValueError("times must be a positive integer")
|
||||
with self._pair_transaction() as conn:
|
||||
return (
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET run_count=run_count+?,last_used_at=? "
|
||||
"WHERE id=?",
|
||||
(times, utc_now_iso(), pair_id),
|
||||
).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def link_image_to_pair(
|
||||
self, pair_id: int, image_id: int, provenance: Dict[str, Any]
|
||||
) -> int:
|
||||
"""Associate an image with immutable run provenance, idempotently."""
|
||||
with self._pair_transaction() as conn:
|
||||
return self._link_image_to_pair(conn, pair_id, image_id, provenance)
|
||||
|
||||
def _link_image_to_pair(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
pair_id: int,
|
||||
image_id: int,
|
||||
provenance: Dict[str, Any],
|
||||
) -> int:
|
||||
"""Associate an image with immutable run provenance, idempotently."""
|
||||
if not isinstance(provenance, dict):
|
||||
raise ValueError("provenance must be an object")
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
except (TypeError, ValueError, RecursionError) as error:
|
||||
raise ValueError("provenance must contain JSON values") from error
|
||||
error = pair_provenance_error(encoded)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
pair = conn.execute(
|
||||
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone()
|
||||
image = conn.execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE id=?", (image_id,)
|
||||
).fetchone()
|
||||
if not pair or not image:
|
||||
raise ValueError("Pair and image must exist")
|
||||
if pair["positive_prompt_id"] != image["prompt_id"]:
|
||||
raise ValueError("Image must belong to the pair's positive prompt")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
|
||||
"VALUES(?,?,?,?) ON CONFLICT(pair_id,image_id,provenance) DO NOTHING",
|
||||
(pair_id, image_id, encoded, utc_now_iso()),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM image_prompt_pairs "
|
||||
"WHERE pair_id=? AND image_id=? AND provenance=?",
|
||||
(pair_id, image_id, encoded),
|
||||
).fetchone()["id"]
|
||||
|
||||
def get_image_prompt_pairs(self, image_id: int) -> List[Dict[str, Any]]:
|
||||
"""Return every recorded pair association with decoded provenance."""
|
||||
rows = (
|
||||
self.model.get_connection()
|
||||
.execute(
|
||||
"SELECT * FROM image_prompt_pairs WHERE image_id=? ORDER BY id",
|
||||
(image_id,),
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
return [
|
||||
{**dict(row), "provenance": json.loads(row["provenance"])} for row in rows
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
# Reusable positive and negative prompts
|
||||
|
||||
Tracking: [#193](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/193).
|
||||
This work builds on the merged Split node in PR #192. Keep the feature unmerged
|
||||
until maintainer live testing is complete.
|
||||
|
||||
## Storage contract
|
||||
|
||||
Keep existing prompt IDs, text hashes, metadata, image links and node schemas.
|
||||
A component can serve either role. Ordered combinations refer to shared prompt
|
||||
rows; an explicitly empty negative uses a null reference. Missing or ambiguous
|
||||
metadata does not mean an empty negative. Pair metadata belongs to the
|
||||
combination; saving another combination does not overwrite component metadata.
|
||||
Existing trim/lowercase hash normalization remains unchanged.
|
||||
|
||||
Saved components are immutable while referenced. To change text, create or reuse
|
||||
a component and save a new combination. Archive combinations that have generation
|
||||
history. Pruning and legacy editing/deletion must respect these dependencies.
|
||||
Historical workflow snapshots remain intact, including their original text.
|
||||
|
||||
## Migration and recovery
|
||||
|
||||
Create pair tables after existing legacy rebuild migrations, in an atomic,
|
||||
repeatable migration. Never scan media during schema initialization. Test fresh,
|
||||
legacy and current databases, repeated startup, injected failures and retries.
|
||||
Preserve old backup compatibility. Migrate and validate a disposable backup copy
|
||||
before replacing live database pages. Reject broken pair schemas, foreign keys,
|
||||
triggers and views. Use SQLite backup APIs so committed WAL data is included.
|
||||
|
||||
## Metadata and scanning
|
||||
|
||||
Resolve positive and negative from the same sampler/output context. Track both
|
||||
node ID and output slot; bound graph traversal and text construction. Unknown
|
||||
conditioning operations and conflicting sampler candidates remain unresolved.
|
||||
Never guess that the first encoder or most recent negative belongs to an image.
|
||||
|
||||
Import high-confidence pairs atomically with their image association and source
|
||||
provenance. Repeated scans must not duplicate components, pairs or image links,
|
||||
regenerate thumbnails, or increment generation run counts. Preserve existing
|
||||
conflicting/manual associations for review. Report new, enriched, unchanged,
|
||||
ambiguous, unresolved and failed results separately.
|
||||
|
||||
## Live node
|
||||
|
||||
Use the shared toolkit and Split foundation. Positive and negative editors have
|
||||
equal size. Only positive receives prepend, append and automatic LoRA triggers.
|
||||
Keep upstream connections authoritative. Library selection previews both fields
|
||||
before an explicit load. Saving text changes creates another reusable combination.
|
||||
Verify Classic and Nodes 2.0 startup, switching both directions, reload, caching,
|
||||
linked inputs, popup behavior and lifecycle cleanup before maintainer testing.
|
||||
|
||||
## Delivery gate
|
||||
|
||||
Keep all existing coverage floors and test suites. Record regression failures
|
||||
before implementing each behavior. Tests use disposable state and the managed
|
||||
CPU host; never mutate the maintainer's running ComfyUI or personal database.
|
||||
Python changes require a ComfyUI restart. Live approval is required before merge.
|
||||
|
||||
## Live testing
|
||||
|
||||
1. Download a database backup from Admin before the first restart. The additive
|
||||
schema migration runs on startup; existing workflows and prompt IDs stay valid.
|
||||
2. Restart ComfyUI, reload the browser, and add **Prompt Manager (+/-)** from
|
||||
**ComfyAssets / Prompts**. Connect CLIP and the two conditioning outputs to the
|
||||
sampler's positive and negative inputs.
|
||||
3. Enter both texts, save a pair, then keep positive unchanged and change negative.
|
||||
Recent should show both combinations with distinct negative excerpts. Selecting
|
||||
one previews both; **Use pair** loads both together.
|
||||
4. Test category/tag autocomplete and metadata opt-in, resizing, light/dark,
|
||||
switching Classic / Nodes 2.0 both ways, and workflow reload. Connected fields
|
||||
stay authoritative; run the node to save connected metadata values.
|
||||
5. Queue the same recipe twice and change only negative once. Usage should count
|
||||
dequeued jobs even when ComfyUI caches the node. Prepend, append and LoRA triggers
|
||||
affect positive only. Gallery text is labelled as recorded during execution or
|
||||
recovered from workflow metadata.
|
||||
6. Run an output metadata scan to enrich existing images. Repeat it: pairs and
|
||||
links must remain deduplicated, with unchanged results rather than new links.
|
||||
Conflicting or ambiguous files need review and are preserved.
|
||||
|
||||
Fresh executions embed bounded text evidence in PNG metadata. A cached node may
|
||||
not emit fresh evidence, so its graph-derived text is explicitly marked inferred.
|
||||
Multiple distinct batch records without an image anchor remain unresolved.
|
||||
Rescanning after interruption is safe through idempotent per-file writes; this
|
||||
version does not persist a filesystem scan cursor across server restarts.
|
||||
The API can request cancellation with `POST /prompt_manager/scan/cancel`; the
|
||||
existing dashboard Hide button continues to hide progress without cancelling.
|
||||
|
||||
Pair APIs are under `/prompt_manager/pairs`: POST saves a combination, GET lists
|
||||
and filters, GET/PATCH/DELETE `/{id}` reads, edits metadata or removes an unused
|
||||
pair. Changing text creates another combination. Null metadata updates preserve
|
||||
existing values; empty strings/lists clear supported fields. Archive a pair with
|
||||
`PATCH {"archived":true}`. GET `/export` provides versioned JSON containing both
|
||||
texts and pair metadata; this is an export format, not a JSON import endpoint.
|
||||
|
||||
## Gallery redesign concept retained
|
||||
|
||||
The maintainer approved the interactive
|
||||
[negative variant timeline concept](concepts/prompt-variant-timeline.html) on
|
||||
2026-10-17. Preserve it for the later gallery redesign; it is not production UI
|
||||
and does not expand the current node work.
|
||||
|
||||
Keep one positive parent and one shared gallery, with red branches for reusable
|
||||
negative variants and smaller dots for repeat runs. Show the selected negative
|
||||
in full directly beneath the unchanged positive; selecting another variant or
|
||||
run updates that text and filters the shared gallery. Comparison shows both
|
||||
negatives. Distinguish explicitly empty negatives from an all-variants view and
|
||||
saved variants from executed runs. Production history needs persisted execution
|
||||
records; the prototype's timeline uses illustrative sample data.
|
||||
|
||||
The Admin upgrade/scan notice remains a separate follow-up: explain automatic
|
||||
schema migration and offer an optional metadata enrichment scan or Later.
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,71 @@
|
||||
# Shared ComfyAssets UI adoption
|
||||
|
||||
Status: approved direction; toolkit/testing foundation in progress.
|
||||
Product UI brainstorming comes before node changes.
|
||||
|
||||
Tracking: [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186)
|
||||
and [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81).
|
||||
|
||||
KikoTools is the validated model. The
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) is the
|
||||
shared source; PromptManager proves the package boundaries before a later
|
||||
KikoTools adoption PR. Other custom nodes can become additional consumers.
|
||||
|
||||
## Approved sequence
|
||||
|
||||
1. Extract reusable controls and test infrastructure from the working KikoTools
|
||||
reference into the toolkit. Keep routes, node schemas and controllers local.
|
||||
2. Bring PromptManager's automated tests up to parity using the pinned toolkit.
|
||||
3. Brainstorm PromptManager node layouts, controls and behavior with the maintainer
|
||||
before changing its production UI. Select one bounded first consumer together.
|
||||
4. Validate shared distribution, themes, cleanup and compatibility through that
|
||||
consumer, then migrate incrementally. Test both packs together as appropriate.
|
||||
5. Backport toolkit adoption to KikoTools after PromptManager proves it, preserving
|
||||
the existing KikoTools appearance, behavior and workflow compatibility.
|
||||
|
||||
KikoTools is not being migrated again during this phase. The original
|
||||
[foundation mockup](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
|
||||
records the approved visual direction and inspiration.
|
||||
|
||||
## PromptManager requirements
|
||||
|
||||
- Preserve existing node names, inputs/outputs, API routes, saved workflows,
|
||||
gallery behavior, and existing test/coverage gates.
|
||||
- Use shared control factories, editor slots, settings/help/window behavior and
|
||||
the ComfyUI adapter where applicable. Keep PromptManager controllers and HTTP
|
||||
behavior in this repository.
|
||||
- Both projects must look identical: one shared set of controls, typography,
|
||||
spacing, colors and interaction states. Follow ComfyUI light/dark mode with
|
||||
our own styling, a blue primary accent and strong red errors/destructive actions.
|
||||
Use compact buttons, pill switches, segmented selections and responsive rows.
|
||||
Review the pilot mockup before production styling. Existing `--pm-*` tokens
|
||||
are migration context, not an approved design to retain.
|
||||
- Theme standalone pages explicitly: `window.open` pages do not inherit the
|
||||
host's CSS variables. Test the agreed palette policy, live changes, fallbacks
|
||||
and persistence, including errors when host settings are unavailable.
|
||||
- Ship a pinned, reproducible browser distribution. Installation must work
|
||||
without npm or KikoTools, including when both packs ship different core versions.
|
||||
- Attribute [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma) as architectural
|
||||
inspiration. Do not copy/vendor his code, styles, or assets.
|
||||
|
||||
## First migration
|
||||
|
||||
With the maintainer, inventory the PromptManager nodes and gallery dialogs and
|
||||
choose one bounded first UI consumer after brainstorming. Record its current behavior with tests before changing it.
|
||||
Verify keyboard opening/closing, focus restoration, accessible labels, confirm
|
||||
and cancel behavior, network failures, and light/dark appearances. Keep the
|
||||
existing gallery and destructive-action browser workflows passing.
|
||||
|
||||
Build shared-source extraction and distribution checks before importing the core.
|
||||
Test both packs alone and together, scoped styling, isolated settings/storage,
|
||||
and independent cleanup. ComfyUI-facing changes require the real-host Classic,
|
||||
Nodes 2.0 and live-switching matrix from the foundation proposal. Standalone
|
||||
dialogs additionally run through PromptManager's existing browser harness.
|
||||
|
||||
## Delivery
|
||||
|
||||
Use small issue-linked PRs and TDD: verify each new behavioral test fails before
|
||||
implementing it, then run relevant suites and required CI checks. Include visual
|
||||
evidence for appearance changes. Migrate additional nodes/dialogs/editors only after
|
||||
the first consumer validates the package boundaries; do not rewrite all pages
|
||||
or change backend schemas as part of this initiative.
|
||||
@@ -281,3 +281,22 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
|
||||
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()
|
||||
|
||||
|
||||
class PromptManagerFocused(PromptManager):
|
||||
"""The editor-first UI uses the existing encoding and storage contract."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
inputs = super().INPUT_TYPES()
|
||||
for name in ("prepend_text", "append_text"):
|
||||
kind, options = inputs["optional"][name]
|
||||
inputs["optional"][name] = (
|
||||
kind,
|
||||
{**options, "default": "", "forceInput": True},
|
||||
)
|
||||
return inputs
|
||||
|
||||
|
||||
class PromptManagerSplit(PromptManagerFocused):
|
||||
"""The persistent library layout shares Focused encoding and storage."""
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Paired positive and negative conditioning with reusable recipe storage."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from .prompt_manager import IO, InputTypeDict, PromptManagerFocused
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.pair_execution import record_pair_execution
|
||||
from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from .utils.prompt_pair_resolver import resolve_text_input
|
||||
from .utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
PENDING_PAIR_FIRST_USE,
|
||||
pair_run_key,
|
||||
)
|
||||
except ImportError:
|
||||
from prompt_manager import IO, InputTypeDict, PromptManagerFocused
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.pair_execution import record_pair_execution
|
||||
from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
from utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
PENDING_PAIR_FIRST_USE,
|
||||
pair_run_key,
|
||||
)
|
||||
|
||||
|
||||
class PromptManagerPair(PromptManagerFocused):
|
||||
"""Keep raw editors as one recipe while encoding their effective texts."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
inputs = super().INPUT_TYPES()
|
||||
inputs["required"] = {
|
||||
"text": inputs["required"]["text"],
|
||||
"negative_text": (
|
||||
IO.STRING,
|
||||
{"default": "", "multiline": True, "dynamicPrompts": True},
|
||||
),
|
||||
"clip": inputs["required"]["clip"],
|
||||
}
|
||||
inputs["hidden"]["extra_pnginfo"] = "EXTRA_PNGINFO"
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = (IO.CONDITIONING, IO.CONDITIONING, IO.STRING, IO.STRING)
|
||||
RETURN_NAMES = ("positive", "negative", "positive_text", "negative_text")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"Positive conditioning with composition and optional LoRA trigger words.",
|
||||
"Negative conditioning from the negative editor, including empty text.",
|
||||
"The effective positive text that was encoded.",
|
||||
"The unchanged negative text that was encoded.",
|
||||
)
|
||||
DESCRIPTION = "Encode and save a reusable positive/negative prompt pair."
|
||||
|
||||
def encode_prompt(
|
||||
self,
|
||||
clip: Any,
|
||||
text: str,
|
||||
negative_text: str = "",
|
||||
category: str = "",
|
||||
tags: str = "",
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
extra_pnginfo: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[Any, Any, str, str]:
|
||||
"""Encode both sides once; database and tracking failures never block CLIP."""
|
||||
if clip is None:
|
||||
raise RuntimeError("ERROR: clip input is invalid: None")
|
||||
if not isinstance(text, str) or not isinstance(negative_text, str):
|
||||
raise ValueError("Positive and negative prompt text must be strings")
|
||||
positive = self._inject_lora_trigger_words(
|
||||
" ".join(
|
||||
part
|
||||
for part in (prepend_text.strip(), text, append_text.strip())
|
||||
if part
|
||||
)
|
||||
)
|
||||
positive_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(positive)
|
||||
)
|
||||
negative_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(negative_text)
|
||||
)
|
||||
record_pair_execution(
|
||||
extra_pnginfo,
|
||||
unique_id,
|
||||
text=text,
|
||||
negative_text=negative_text,
|
||||
positive_text=positive,
|
||||
negative_effective_text=negative_text,
|
||||
prepend_text=prepend_text.strip(),
|
||||
append_text=append_text.strip(),
|
||||
)
|
||||
pair_id = prompt_id = None
|
||||
known = isinstance(prompt, dict) and unique_id is not None
|
||||
node_id = str(unique_id)
|
||||
used_fields = (
|
||||
{
|
||||
field
|
||||
for source, field in run_prompt_components(prompt)
|
||||
if source == node_id
|
||||
}
|
||||
if known
|
||||
else {"text"}
|
||||
)
|
||||
is_positive = "text" in used_fields
|
||||
pair_used = node_id in intrinsic_pair_nodes(prompt) if known else True
|
||||
try:
|
||||
pair_id = self.db.save_prompt_pair(
|
||||
text,
|
||||
negative_text,
|
||||
category=category.strip() or None,
|
||||
tags=self._parse_tags(tags),
|
||||
)
|
||||
pair = self.db.get_prompt_pair(pair_id)
|
||||
prompt_id = pair["positive_prompt_id"]
|
||||
resolved_positive = (
|
||||
resolve_text_input(prompt, node_id, "text") if known else None
|
||||
)
|
||||
resolved_negative = (
|
||||
resolve_text_input(prompt, node_id, "negative_text") if known else None
|
||||
)
|
||||
positive_seen = resolved_positive is not None and generate_prompt_hash(
|
||||
resolved_positive
|
||||
) == generate_prompt_hash(text)
|
||||
pair_seen = (
|
||||
positive_seen
|
||||
and resolved_negative is not None
|
||||
and generate_prompt_hash(resolved_negative)
|
||||
== generate_prompt_hash(negative_text)
|
||||
)
|
||||
positive_pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
|
||||
pair_pending = getattr(
|
||||
self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE
|
||||
)
|
||||
counted = set()
|
||||
for field, component_text, resolved_text, component_id in (
|
||||
("text", text, resolved_positive, prompt_id),
|
||||
(
|
||||
"negative_text",
|
||||
negative_text,
|
||||
resolved_negative,
|
||||
pair["negative_prompt_id"],
|
||||
),
|
||||
):
|
||||
component_hash = generate_prompt_hash(component_text)
|
||||
if (
|
||||
field not in used_fields
|
||||
or not component_id
|
||||
or component_hash in counted
|
||||
):
|
||||
continue
|
||||
counted.add(component_hash)
|
||||
seen_at_queue = (
|
||||
resolved_text is not None
|
||||
and generate_prompt_hash(resolved_text) == component_hash
|
||||
)
|
||||
runs = positive_pending.consume(component_hash) + int(not seen_at_queue)
|
||||
if runs:
|
||||
self.db.record_prompt_use(component_id, times=runs)
|
||||
if pair_used:
|
||||
runs = pair_pending.consume(pair_run_key(text, negative_text)) + int(
|
||||
not pair_seen
|
||||
)
|
||||
if runs:
|
||||
self.db.record_prompt_pair_use(pair_id, times=runs)
|
||||
if is_positive:
|
||||
self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=positive,
|
||||
additional_data={
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"positive_text": positive,
|
||||
"negative_text": negative_text,
|
||||
"positive_storage_text": text.strip(),
|
||||
"negative_storage_text": negative_text.strip(),
|
||||
"prepend_text": prepend_text.strip(),
|
||||
"append_text": append_text.strip(),
|
||||
"final_text": positive,
|
||||
"category": category.strip() or None,
|
||||
"tags": self._parse_tags(tags) or [],
|
||||
},
|
||||
push_to_queue=not positive_seen or (pair_used and not pair_seen),
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not save or track paired prompt: {error}")
|
||||
try:
|
||||
self.comfyui_integration.register_prompt(
|
||||
f"promptmanager_pair_{time.time_ns()}",
|
||||
positive,
|
||||
{
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"negative_text": negative_text,
|
||||
"category": category.strip() or None,
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
return positive_conditioning, negative_conditioning, positive, negative_text
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
clip: Any,
|
||||
text: str = "",
|
||||
negative_text: str = "",
|
||||
category: str = "",
|
||||
tags: str = "",
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Either editor, composition or pair metadata invalidates the node cache."""
|
||||
serialized = json.dumps(
|
||||
[text, negative_text, prepend_text, append_text, category, tags]
|
||||
)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
@@ -22,6 +22,7 @@ from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
@@ -240,6 +241,7 @@ async def _gzip_middleware(request, handler):
|
||||
|
||||
|
||||
class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
@@ -262,12 +264,12 @@ class PromptManagerAPI(
|
||||
db (PromptDatabase): Database connection and operations handler
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the PromptManager API with database connection and cleanup."""
|
||||
def __init__(self, db: PromptDatabase | None = None) -> None:
|
||||
"""Initialize the API, using an injected database before any cleanup."""
|
||||
self.logger = get_logger("prompt_manager.api")
|
||||
self.logger.info("Initializing PromptManager API")
|
||||
|
||||
self.db = PromptDatabase()
|
||||
self.db = db if db is not None else PromptDatabase()
|
||||
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
@@ -391,6 +393,7 @@ class PromptManagerAPI(
|
||||
|
||||
# ── Register domain-specific routes from mixins ───────────────
|
||||
|
||||
self._register_pair_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
self._register_admin_routes(routes)
|
||||
|
||||
@@ -26,8 +26,12 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...database.pair_ingestion import ingest_resolved_pair
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
except ImportError:
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
|
||||
try:
|
||||
@@ -40,6 +44,20 @@ VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
|
||||
SCAN_BATCH_SIZE = 50 # Files per executor call during the output scan
|
||||
MAX_SAFETY_BACKUPS = 10 # '<db>.backup_*' files kept next to the live database
|
||||
MAX_SCAN_FILES = 50_000 # Upper bound on media files one scan will look at
|
||||
SCAN_COUNT_KEYS = (
|
||||
"processed",
|
||||
"found",
|
||||
"added",
|
||||
"linked",
|
||||
"enriched",
|
||||
"unchanged",
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
"conflict",
|
||||
"failed",
|
||||
"pairs_added",
|
||||
"pair_links_added",
|
||||
)
|
||||
MAX_SCAN_DEPTH = 12 # Directory levels below an output root that are scanned
|
||||
# Files the duplicate cleaner may delete: everything the scan reports, plus
|
||||
# the common single-f TIFF spelling. Anything else inside the output
|
||||
@@ -359,6 +377,10 @@ class AdminRoutesMixin:
|
||||
async def scan_images_route(request):
|
||||
return await self.scan_images(request)
|
||||
|
||||
@routes.post("/prompt_manager/scan/cancel")
|
||||
async def cancel_scan_route(request):
|
||||
return await self.cancel_scan(request)
|
||||
|
||||
@routes.get("/prompt_manager/scan/status")
|
||||
async def scan_status_route(request):
|
||||
return await self.scan_status(request)
|
||||
@@ -1280,6 +1302,14 @@ class AdminRoutesMixin:
|
||||
job = self._start_scan_job()
|
||||
return await self._stream_scan_job(request, job)
|
||||
|
||||
async def cancel_scan(self, request):
|
||||
"""Stop between file transactions; an in-flight extraction can finish."""
|
||||
job = self._scan_job
|
||||
requested = bool(job and job.running)
|
||||
if requested:
|
||||
job.cancelled = True
|
||||
return web.json_response({"success": True, "cancellation_requested": requested})
|
||||
|
||||
async def scan_status(self, request):
|
||||
"""Whether an output scan is running, and its latest progress event."""
|
||||
job = self._scan_job
|
||||
@@ -1351,7 +1381,7 @@ class AdminRoutesMixin:
|
||||
media_files = await self._run_in_executor(
|
||||
self._collect_output_media_sync, output_dirs
|
||||
)
|
||||
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
if media_files:
|
||||
yield self._scan_progress(
|
||||
5, f"Found {len(media_files)} media files to process...", counts
|
||||
@@ -1364,7 +1394,9 @@ class AdminRoutesMixin:
|
||||
f"found={counts['found']}, new_prompts_added={counts['added']}, "
|
||||
f"images_linked_to_existing={counts['linked']}"
|
||||
)
|
||||
yield {"type": "complete", **counts, "directories": dir_names}
|
||||
job = self._scan_job
|
||||
final_type = "cancelled" if job and job.cancelled else "complete"
|
||||
yield {"type": final_type, **counts, "directories": dir_names}
|
||||
|
||||
except Exception:
|
||||
self.logger.exception("Scan error")
|
||||
@@ -1380,6 +1412,8 @@ class AdminRoutesMixin:
|
||||
"""
|
||||
total = len(media_files)
|
||||
for batch_start in range(0, total, SCAN_BATCH_SIZE):
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
return
|
||||
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
|
||||
batch_results = await self._run_in_executor(
|
||||
self._extract_batch_metadata_sync, batch
|
||||
@@ -1405,7 +1439,7 @@ class AdminRoutesMixin:
|
||||
"progress": progress,
|
||||
"status": status,
|
||||
"processed": counts.get("processed", 0),
|
||||
"found": counts.get("found", 0),
|
||||
**{key: counts.get(key, 0) for key in SCAN_COUNT_KEYS},
|
||||
}
|
||||
|
||||
def _collect_output_media_sync(self, output_dirs):
|
||||
@@ -1431,25 +1465,64 @@ class AdminRoutesMixin:
|
||||
|
||||
def _ingest_scan_batch_sync(self, batch_results):
|
||||
"""Store the prompts found in a metadata batch (blocking DB work)."""
|
||||
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
for media_file, metadata in batch_results:
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
break
|
||||
counts["processed"] += 1
|
||||
try:
|
||||
outcome = self._ingest_scanned_file(media_file, metadata)
|
||||
outcome = self._ingest_scanned_file(media_file, metadata, detailed=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {media_file.name}: {e}")
|
||||
counts["failed"] += 1
|
||||
continue
|
||||
if outcome is None:
|
||||
continue
|
||||
counts["found"] += 1
|
||||
if outcome in ("added", "linked"):
|
||||
counts[outcome] += 1
|
||||
status = outcome["status"] if isinstance(outcome, dict) else outcome
|
||||
if status in (
|
||||
"added",
|
||||
"linked",
|
||||
"enriched",
|
||||
"unchanged",
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
"conflict",
|
||||
"failed",
|
||||
):
|
||||
counts[status] += 1
|
||||
if isinstance(outcome, dict):
|
||||
counts["pairs_added"] += outcome.get("pairs_added", 0)
|
||||
counts["pair_links_added"] += outcome.get("links_added", 0)
|
||||
return counts
|
||||
|
||||
def _ingest_scanned_file(self, media_file, metadata):
|
||||
"""Save or link one scanned file; returns 'added', 'linked', 'found' or None."""
|
||||
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
|
||||
if not metadata:
|
||||
return None
|
||||
canonical = dict(metadata)
|
||||
for key in ("prompt", "workflow"):
|
||||
if isinstance(canonical.get(key), str):
|
||||
try:
|
||||
canonical[key] = json.loads(canonical[key])
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return None
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved":
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
)
|
||||
return result if detailed else result["status"]
|
||||
if resolution["state"] == "ambiguous":
|
||||
return "ambiguous"
|
||||
if resolution["state"] == "unresolved":
|
||||
if canonical.get("prompt") or not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
@@ -1465,13 +1538,15 @@ class AdminRoutesMixin:
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
try:
|
||||
self.db.link_image_to_prompt(existing["id"], str(media_file))
|
||||
image_id = self.db.link_image_to_prompt(
|
||||
existing["id"], str(media_file), metadata=canonical
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to link {media_file.name} to existing prompt: {e}"
|
||||
)
|
||||
return "found"
|
||||
return "linked"
|
||||
return "linked" if image_id else "unchanged"
|
||||
|
||||
prompt_id = self.db.save_prompt(
|
||||
prompt_text,
|
||||
@@ -1484,7 +1559,7 @@ class AdminRoutesMixin:
|
||||
if not prompt_id:
|
||||
return "found"
|
||||
try:
|
||||
self.db.link_image_to_prompt(prompt_id, str(media_file))
|
||||
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
|
||||
return "added"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Reusable ordered prompt combinations, separate from legacy prompt CRUD."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .prompts import parse_page_params
|
||||
|
||||
try:
|
||||
from ...database.pairs import PairDependencyError
|
||||
from ...utils.validators import (
|
||||
validate_category,
|
||||
validate_prompt_text,
|
||||
validate_rating,
|
||||
validate_tags,
|
||||
)
|
||||
except ImportError:
|
||||
from database.pairs import PairDependencyError
|
||||
from utils.validators import (
|
||||
validate_category,
|
||||
validate_prompt_text,
|
||||
validate_rating,
|
||||
validate_tags,
|
||||
)
|
||||
|
||||
|
||||
def pair_response(handler: Callable) -> Callable:
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception:
|
||||
self.logger.exception("Prompt pair operation failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Prompt pair operation failed"}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def metadata_fields(data: dict) -> dict:
|
||||
fields = {}
|
||||
for name in ("title", "category", "tags", "rating", "notes"):
|
||||
if name not in data:
|
||||
continue
|
||||
value = data[name]
|
||||
if name == "tags":
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Tags must be a list of strings")
|
||||
validate_tags(value)
|
||||
elif name == "rating":
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("Rating must be an integer")
|
||||
validate_rating(value)
|
||||
else:
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError(f"{name} must be a string")
|
||||
if name == "category":
|
||||
validate_category(value)
|
||||
elif value is not None and len(value) > 10000:
|
||||
raise ValueError(f"{name} is too long")
|
||||
fields[name] = value
|
||||
return fields
|
||||
|
||||
|
||||
async def object_body(request: web.Request) -> dict:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def missing_pair() -> web.Response:
|
||||
return web.json_response({"success": False, "error": "Pair not found"}, status=404)
|
||||
|
||||
|
||||
class PairRoutesMixin:
|
||||
def _register_pair_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/pairs/export")(self.export_prompt_pairs)
|
||||
routes.get("/prompt_manager/pairs")(self.list_prompt_pairs)
|
||||
routes.post("/prompt_manager/pairs")(self.save_prompt_pair)
|
||||
routes.get("/prompt_manager/pairs/{pair_id}")(self.get_prompt_pair)
|
||||
routes.patch("/prompt_manager/pairs/{pair_id}")(self.update_prompt_pair)
|
||||
routes.delete("/prompt_manager/pairs/{pair_id}")(self.delete_prompt_pair)
|
||||
|
||||
@pair_response
|
||||
async def save_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
data = await object_body(request)
|
||||
positive = data.get("positive_text")
|
||||
negative = data.get("negative_text")
|
||||
validate_prompt_text(positive)
|
||||
if not isinstance(negative, str):
|
||||
raise ValueError(
|
||||
"negative_text must be explicit; use an empty string for none"
|
||||
)
|
||||
if negative.strip():
|
||||
validate_prompt_text(negative)
|
||||
fields = metadata_fields(data)
|
||||
pair_id = await self._run_in_executor(
|
||||
self.db.save_prompt_pair, positive, negative, **fields
|
||||
)
|
||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||
return web.json_response({"success": True, "pair": pair})
|
||||
|
||||
@pair_response
|
||||
async def get_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
pair = await self._run_in_executor(
|
||||
self.db.get_prompt_pair, int(request.match_info["pair_id"])
|
||||
)
|
||||
return (
|
||||
web.json_response({"success": True, "pair": pair})
|
||||
if pair
|
||||
else missing_pair()
|
||||
)
|
||||
|
||||
@pair_response
|
||||
async def list_prompt_pairs(self, request: web.Request) -> web.Response:
|
||||
limit, offset = parse_page_params(request.query)
|
||||
archived = request.query.get("include_archived", "false")
|
||||
if archived not in ("true", "false"):
|
||||
raise ValueError("include_archived must be true or false")
|
||||
result = await self._run_in_executor(
|
||||
self.db.list_prompt_pairs,
|
||||
text=request.query.get("text"),
|
||||
category=request.query.get("category"),
|
||||
tags=request.query.getall("tag", []),
|
||||
include_archived=archived == "true",
|
||||
sort=request.query.get("sort", "newest"),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@pair_response
|
||||
async def update_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
pair_id = int(request.match_info["pair_id"])
|
||||
data = await object_body(request)
|
||||
allowed = {"title", "category", "tags", "rating", "notes", "archived"}
|
||||
if not data or set(data) - allowed:
|
||||
raise ValueError(
|
||||
"Update metadata or archive; save a new pair to change text"
|
||||
)
|
||||
fields = metadata_fields(data)
|
||||
if "archived" in data:
|
||||
if not isinstance(data["archived"], bool) or fields:
|
||||
raise ValueError("Archive must be a separate boolean update")
|
||||
updated = await self._run_in_executor(
|
||||
self.db.archive_prompt_pair, pair_id, data["archived"]
|
||||
)
|
||||
else:
|
||||
updated = await self._run_in_executor(
|
||||
self.db.update_prompt_pair_metadata, pair_id, **fields
|
||||
)
|
||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||
if not updated and not pair:
|
||||
return missing_pair()
|
||||
return web.json_response({"success": True, "pair": pair})
|
||||
|
||||
@pair_response
|
||||
async def delete_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
deleted = await self._run_in_executor(
|
||||
self.db.delete_prompt_pair, int(request.match_info["pair_id"])
|
||||
)
|
||||
return web.json_response({"success": True}) if deleted else missing_pair()
|
||||
|
||||
@pair_response
|
||||
async def export_prompt_pairs(self, request: web.Request) -> web.Response:
|
||||
pairs = []
|
||||
offset = 0
|
||||
while True:
|
||||
result = await self._run_in_executor(
|
||||
self.db.list_prompt_pairs,
|
||||
include_archived=True,
|
||||
limit=500,
|
||||
offset=offset,
|
||||
)
|
||||
for pair in result["pairs"]:
|
||||
pairs.append(
|
||||
{
|
||||
key: pair[key]
|
||||
for key in (
|
||||
"positive_text",
|
||||
"negative_text",
|
||||
"title",
|
||||
"category",
|
||||
"tags",
|
||||
"rating",
|
||||
"notes",
|
||||
"archived",
|
||||
)
|
||||
}
|
||||
)
|
||||
offset += len(result["pairs"])
|
||||
if not result["has_more"] or not result["pairs"]:
|
||||
break
|
||||
return web.json_response(
|
||||
{"schema_version": 1, "kind": "prompt-manager-pairs", "pairs": pairs},
|
||||
headers={"Content-Disposition": 'attachment; filename="prompt-pairs.json"'},
|
||||
)
|
||||
@@ -6,6 +6,11 @@ import os
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.pairs import PairDependencyError
|
||||
except ImportError:
|
||||
from database.pairs import PairDependencyError
|
||||
from urllib.parse import quote
|
||||
|
||||
# Upper bounds on page sizes and offsets for every list endpoint. The offset
|
||||
@@ -793,6 +798,8 @@ class PromptRoutesMixin:
|
||||
status=404,
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid prompt ID"}, status=400
|
||||
@@ -840,6 +847,8 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "Prompt not found"}, status=404
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except sqlite3.IntegrityError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Another prompt already has this text"},
|
||||
@@ -1081,6 +1090,8 @@ class PromptRoutesMixin:
|
||||
}
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk delete error: {e}")
|
||||
return web.json_response(
|
||||
|
||||
@@ -16,6 +16,7 @@ class ScanJob:
|
||||
def __init__(self):
|
||||
self.last_event = None
|
||||
self.finished = False
|
||||
self.cancelled = False
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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';
|
||||
|
||||
test.beforeEach(async ({page, browserName}) => {
|
||||
await startCoverage(page, browserName);
|
||||
page.classicErrors = [];
|
||||
page.on('pageerror', error => page.classicErrors.push(error.message));
|
||||
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
|
||||
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
|
||||
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
|
||||
await page.goto('/prompt_manager/test');
|
||||
await page.evaluate(() => {
|
||||
document.body.replaceChildren();
|
||||
document.body.style.setProperty('--bg-color','#222');
|
||||
window.fixtureApp={extensions:[],registerExtension(extension){this.extensions.push(extension);},ui:{settings:new EventTarget()},canvas:{},graph:{setDirtyCanvas(){}}};
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){
|
||||
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
|
||||
this.widgets_values=[]; this.inputs=[];
|
||||
}
|
||||
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
||||
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
|
||||
}
|
||||
await fixtureApp.extensions[0].beforeRegisterNodeDef(Node,{name:'PromptManager'},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated();
|
||||
});
|
||||
await expect(page.locator('.pm-classic')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterEach(async ({page,browserName}) => {
|
||||
await saveCoverage(page,browserName);
|
||||
expect(page.classicErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
|
||||
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
|
||||
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
|
||||
const response=page.waitForRequest('**/prompt_manager/search?**');
|
||||
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
|
||||
expect(new URL((await response).url()).searchParams.get('tags')).toBe('film grain');
|
||||
const result=page.getByRole('button',{name:/A new portrait/});
|
||||
await result.focus();await result.press('Enter');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['A new portrait','Portrait','film grain']);
|
||||
await expect(result).toHaveAttribute('aria-pressed','true');
|
||||
const released=await page.evaluate(()=>{
|
||||
const previous=node.resultsSection.querySelector('button');
|
||||
node.displayResults([{text:'Replacement',tags:[]}]);
|
||||
previous.click();
|
||||
return node.widgets.find(widget=>widget.name==='text').value;
|
||||
});
|
||||
expect(released).toBe('A new portrait');
|
||||
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('Classic detaches native text synchronization on removal',async({page})=>{
|
||||
await page.waitForTimeout(60);
|
||||
const values=await page.evaluate(()=>{
|
||||
const widget=node.widgets.find(item=>item.name==='text');
|
||||
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
|
||||
const before=widget.value;node.onRemoved();
|
||||
widget.inputEl.value='After removal';widget.inputEl.dispatchEvent(new Event('input'));
|
||||
return [before,widget.value];
|
||||
});
|
||||
expect(values).toEqual(['Before removal','Before removal']);
|
||||
});
|
||||
|
||||
test('Classic follows host themes, keeps restore behavior and disposes resources',async({page})=>{
|
||||
const panel=page.locator('.pm-classic');
|
||||
await expect(panel).toHaveAttribute('data-ca-theme','dark');
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#fff'));
|
||||
await expect(panel).toHaveAttribute('data-ca-theme','light');
|
||||
await page.evaluate(()=>{node.properties.resultTimeout=0;node.displayResults([{text:'Example',tags:[]}]);node.hideResults();});
|
||||
await page.getByRole('button',{name:/Results auto-hidden/}).click();
|
||||
await expect(page.getByRole('button',{name:/Example/})).toBeVisible();
|
||||
const state=await page.evaluate(()=>{
|
||||
node.showNotification('Failure','error');node.onRemoved();
|
||||
const disposed=node._classicScope.signal.aborted;
|
||||
node.showNotification('Late response','info');
|
||||
return {disposed,notifications:document.querySelectorAll('.pm-classic-notification').length,style:node._styleSheet.isConnected};
|
||||
});
|
||||
expect(state).toEqual({disposed:true,notifications:0,style:false});
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222'));
|
||||
await expect(panel).toHaveAttribute('data-ca-theme','light');
|
||||
});
|
||||
|
||||
test('Classic handles empty queries, empty results, failed requests and late responses',async({page})=>{
|
||||
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results')).toContainText('Enter search text');
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[]}}));
|
||||
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results')).toContainText('No prompts found');
|
||||
await page.evaluate(async()=>{
|
||||
node.callNodeMethod=async()=>{throw new Error('<img src=x onerror=alert(1)>');};
|
||||
await node.loadRecentPrompts();
|
||||
});
|
||||
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('<img src=x');
|
||||
await expect(page.locator('.pm-classic-results img')).toHaveCount(0);
|
||||
const late=await page.evaluate(async()=>{
|
||||
let finish; node.callNodeMethod=()=>new Promise(resolve=>{finish=resolve;});
|
||||
const loading=node.loadRecentPrompts();node.onRemoved();finish({results:[{text:'Late'}]});await loading;
|
||||
return {notifications:node._classicNotifications.size,late:node.resultsSection.textContent.includes('Late')};
|
||||
});
|
||||
expect(late).toEqual({notifications:0,late:false});
|
||||
});
|
||||
|
||||
test('shipped shared factories keep keyboard behavior and theme isolation across mounts',async({page})=>{
|
||||
await page.evaluate(async()=>{
|
||||
const ui=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const scope=ui.createScope();const panel=document.createElement('div');panel.className='ca-tools';panel.id='shared-contract';
|
||||
window.shared={ui,scope,panel,events:[]};
|
||||
document.body.append(panel);
|
||||
ui.mountTheme(panel,null,scope);
|
||||
const save=ui.createButton('Save contract','ca-primary');scope.listen(save,'click',()=>shared.events.push('save'));
|
||||
panel.append(save,ui.createSlider('Size',{id:'contract-size',min:0,max:100,step:1,value:50,unit:'px'},scope,value=>shared.events.push(value)),ui.createSwitch('Enabled','','contract-enabled'),ui.createField('Order',ui.createSelect([['name','Name'],['date','Date']],''),'contract-order'),ui.createSegments('View',[['grid','Grid'],['list','List']],'contract-view',scope,value=>shared.events.push(value)));
|
||||
});
|
||||
await page.getByRole('button',{name:'Save contract',exact:true}).press('Enter');
|
||||
await page.getByRole('slider',{name:'Size'}).press('ArrowRight');
|
||||
await expect(page.locator('#shared-contract output')).toHaveText('51 px');
|
||||
await page.getByRole('switch',{name:'Enabled'}).uncheck();
|
||||
await page.getByLabel('Order').selectOption('date');
|
||||
await page.getByRole('radio',{name:'List',exact:true}).check();
|
||||
expect(await page.evaluate(()=>shared.events)).toEqual(['save',51,'list']);
|
||||
await page.evaluate(()=>{document.body.style.removeProperty('--bg-color');fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));});
|
||||
await page.emulateMedia({colorScheme:'light'});
|
||||
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','light');
|
||||
await page.emulateMedia({colorScheme:'dark'});
|
||||
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
|
||||
await page.evaluate(()=>{shared.scope.dispose();document.body.style.setProperty('--bg-color','#fff');});
|
||||
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
|
||||
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme','light');
|
||||
});
|
||||
|
||||
test('Classic test button reports success and failure and releases expired notifications',async({page})=>{
|
||||
await page.clock.install();
|
||||
await page.evaluate(()=>{node.properties.showTestButton=true;node.createSearchUI(node._classicContainer);});
|
||||
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results')).toContainText('API Connection Successful');
|
||||
await page.route('**/prompt_manager/test',route=>route.fulfill({status:503,body:'unavailable'}));
|
||||
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('503');
|
||||
await page.route('**/prompt_manager/test',route=>route.fulfill({json:{success:false,message:'Not ready'}}));
|
||||
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('Not ready');
|
||||
await page.clock.fastForward(3001);
|
||||
await expect(page.locator('.pm-classic-notification')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,489 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
for (const variant of ['Focused', 'Split']) {
|
||||
test.describe(variant, () => {
|
||||
const isSplit = variant === 'Split';
|
||||
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
|
||||
async function openLibrary(page) {
|
||||
if (isSplit) {
|
||||
await expect(page.locator('.pm-editor-library')).toBeVisible();
|
||||
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
|
||||
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,browserName})=>{
|
||||
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
|
||||
await startCoverage(page,browserName);
|
||||
page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.push(error.message));
|
||||
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
|
||||
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
|
||||
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
|
||||
await page.goto('/prompt_manager/test');
|
||||
await page.evaluate(async variant=>{
|
||||
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
setSize(size){this.size=size;}setDirtyCanvas(){}
|
||||
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
|
||||
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
|
||||
}
|
||||
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated();
|
||||
},variant);
|
||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
||||
|
||||
test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
|
||||
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
|
||||
await page.route('**/prompt_manager/search?**',route=>{
|
||||
requests.push(new URL(route.request().url()));
|
||||
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
|
||||
});
|
||||
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
|
||||
await openLibrary(page);
|
||||
const category=page.getByLabel('Filter category',{exact:true});
|
||||
await category.fill('por');
|
||||
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
|
||||
await category.press('ArrowDown');
|
||||
await category.press('Enter');
|
||||
expect(requests).toHaveLength(0);
|
||||
const tags=page.getByLabel('Filter tags',{exact:true});
|
||||
await tags.fill('stu');
|
||||
await page.getByRole('option',{name:'studio',exact:true}).click();
|
||||
await expect(tags).toHaveValue('studio, ');
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait');
|
||||
expect(requests[0].searchParams.get('text')).toBe('portrait');
|
||||
expect(requests[0].searchParams.get('category')).toBe('Portrait');
|
||||
expect(requests[0].searchParams.get('tags')).toBe('studio,film grain');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']);
|
||||
await page.locator('.pm-editor-result').click();
|
||||
await category.fill('Landscape');
|
||||
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
|
||||
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
|
||||
expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
|
||||
await expect(category).toHaveValue('Landscape');
|
||||
await expect(tags).toHaveValue('studio, ');
|
||||
let recentRequest;
|
||||
await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});});
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
|
||||
expect(recentRequest.searchParams.has('category')).toBe(false);
|
||||
expect(recentRequest.searchParams.has('tags')).toBe(false);
|
||||
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
|
||||
else await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
});
|
||||
|
||||
test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
|
||||
await openLibrary(page);
|
||||
await page.getByLabel('Filter category',{exact:true}).fill('New category');
|
||||
await page.getByLabel('Filter category',{exact:true}).press('Enter');
|
||||
await expect.poll(()=>requests.length).toBe(1);
|
||||
expect(requests[0].searchParams.get('category')).toBe('New category');
|
||||
await page.getByLabel('Filter category',{exact:true}).fill('');
|
||||
await page.getByLabel('Filter tags',{exact:true}).fill('custom, ');
|
||||
await page.getByLabel('Filter tags',{exact:true}).press('Enter');
|
||||
await expect.poll(()=>requests.length).toBe(2);
|
||||
expect(requests[1].searchParams.get('tags')).toBe('custom');
|
||||
});
|
||||
|
||||
test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => {
|
||||
await page.route('**/prompt_manager/tags/suggest?**', route => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
|
||||
});
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const tags = page.getByLabel('Tags',{exact:true});
|
||||
await tags.fill('portrait, bl');
|
||||
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
|
||||
await tags.press('ArrowDown');
|
||||
await tags.press('Enter');
|
||||
await expect(tags).toHaveValue('portrait, blue eyes, ');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[2])).toBe('portrait, blue eyes, ');
|
||||
await tags.fill('bl');
|
||||
await page.getByRole('option',{name:'blue sky',exact:true}).click();
|
||||
await expect(tags).toHaveValue('blue sky, ');
|
||||
await tags.fill('portrait, ');
|
||||
await expect(page.getByRole('option',{name:'portrait',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
|
||||
await tags.press('Escape');
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
await tags.fill('bl');
|
||||
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
|
||||
await page.locator('.pm-focused').dispatchEvent('scroll');
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
await tags.fill('A brand new tag');
|
||||
await tags.press('Tab');
|
||||
await expect(tags).toHaveValue('A brand new tag');
|
||||
});
|
||||
|
||||
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
|
||||
let release;
|
||||
let mode='delayed';
|
||||
await page.route('**/prompt_manager/tags/suggest?**', async route => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
if(q === 'old') await new Promise(resolve=>{release=resolve;});
|
||||
if(mode === 'error') return route.fulfill({status:503,body:'Unavailable'});
|
||||
await route.fulfill({json:{success:true,suggestions:[{name:q+' result'}]}}).catch(()=>{});
|
||||
});
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const tags=page.getByLabel('Tags',{exact:true});
|
||||
await tags.fill('old');
|
||||
await expect.poll(()=>Boolean(release)).toBe(true);
|
||||
await tags.fill('new');
|
||||
await expect(page.getByRole('option',{name:'new result',exact:true})).toBeVisible();
|
||||
release();
|
||||
await expect(page.getByRole('option',{name:'old result',exact:true})).toHaveCount(0);
|
||||
mode='error';
|
||||
await tags.fill('unavailable');
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
await expect(tags).toHaveValue('unavailable');
|
||||
mode='ok';
|
||||
await tags.fill('retry');
|
||||
await expect(page.getByRole('option',{name:'retry result',exact:true})).toBeVisible();
|
||||
await page.evaluate(()=>node.onRemoved());
|
||||
await expect(page.getByRole('listbox')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const category=page.getByLabel('Category',{exact:true});
|
||||
await category.fill('por');
|
||||
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeVisible();
|
||||
await category.press('ArrowUp');
|
||||
await expect(page.getByRole('option',{name:'Portrait studio',exact:true})).toHaveAttribute('aria-selected','true');
|
||||
await category.press('Enter');
|
||||
await expect(category).toHaveValue('Portrait studio');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[1])).toBe('Portrait studio');
|
||||
await category.fill('por');
|
||||
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
|
||||
await category.dispatchEvent('keydown',{key:'ArrowDown',isComposing:true});
|
||||
await expect(category).not.toHaveAttribute('aria-activedescendant',/.+/);
|
||||
await page.getByLabel('Tags',{exact:true}).focus();
|
||||
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
|
||||
await category.fill('New category');
|
||||
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
|
||||
await expect(category).toHaveValue('New category');
|
||||
});
|
||||
|
||||
test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
|
||||
await page.evaluate(async()=>{
|
||||
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
|
||||
const second=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=second');
|
||||
window.completionFixture={scope:createScope(),selected:[],inserted:[]};
|
||||
const one=first.createSuggestionView(completionFixture.scope,null);
|
||||
const two=second.createSuggestionView(completionFixture.scope,null,{label:'Other pack'});
|
||||
completionFixture.views=[one,two];
|
||||
one.hide();
|
||||
const items=[{name:'plain'},{name:'tag',type:'tag'},{name:'model',type:'lora'},{name:'nested',type:'embedding',fullPath:'folder/nested'},{name:'flat',type:'embedding',fullPath:'flat'}];
|
||||
one.render(items,index=>{completionFixture.selected.push(index);one.select(index);},item=>completionFixture.inserted.push(item.name));
|
||||
one.select(-1);
|
||||
one.position({left:innerWidth-5,bottom:innerHeight,top:innerHeight-20});
|
||||
two.render([{name:'Other pack suggestion'}],()=>{},()=>{});
|
||||
two.position({left:0,bottom:5,top:0});
|
||||
});
|
||||
const lists=page.getByRole('listbox');
|
||||
const ids=await lists.evaluateAll(elements=>elements.map(element=>element.id));
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
await page.getByRole('option',{name:'[LORA] model'}).hover();
|
||||
await expect(page.getByRole('option',{name:'[LORA] model'})).toHaveAttribute('aria-selected','true');
|
||||
await page.getByRole('option',{name:'[EMB] folder/nested'}).click();
|
||||
expect(await page.evaluate(()=>completionFixture.inserted)).toEqual(['nested']);
|
||||
await page.evaluate(()=>completionFixture.scope.dispose());
|
||||
await expect(lists).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
|
||||
await openLibrary(page);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.getByRole('button',{name:/Library portrait/}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
|
||||
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait');
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.getByLabel('Prepend',{exact:true}).fill('trigger');
|
||||
await page.getByLabel('Append',{exact:true}).fill('detailed');
|
||||
await page.getByText('Composed text preview',{exact:true}).click();
|
||||
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
|
||||
});
|
||||
|
||||
test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
await page.getByLabel('Category',{exact:true}).fill('Saved category');
|
||||
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
|
||||
await expect(page.getByLabel('Category',{exact:true})).toBeVisible();
|
||||
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category');
|
||||
await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();});
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden();
|
||||
await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible();
|
||||
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
|
||||
});
|
||||
|
||||
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
|
||||
let requested;
|
||||
await page.route('**/prompt_manager/search?**', async route => {
|
||||
requested = new URL(route.request().url());
|
||||
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
|
||||
});
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
|
||||
await openLibrary(page);
|
||||
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
|
||||
await query.fill('fox tag:"film grain"');
|
||||
await query.press('Enter');
|
||||
await page.getByRole('button',{name:/Selected fox/}).click();
|
||||
expect(requested.searchParams.get('text')).toBe('fox');
|
||||
expect(requested.searchParams.get('tags')).toBe('film grain');
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft');
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals');
|
||||
await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
|
||||
});
|
||||
|
||||
test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
|
||||
let requests=0;
|
||||
let mode='empty';
|
||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||
await page.route('**/prompt_manager/search?**', async route => {
|
||||
requests++;
|
||||
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
|
||||
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
|
||||
});
|
||||
await openLibrary(page);
|
||||
const search=page.getByRole('button',{name:'Search',exact:true});
|
||||
await search.click();
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
|
||||
expect(requests).toBe(0);
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
|
||||
await search.click();
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
|
||||
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
|
||||
mode='error';
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`);
|
||||
await expect(page.locator('.pm-editor-library img')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
|
||||
mode='http';
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toContainText('HTTP 503');
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
|
||||
mode='empty';
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
|
||||
});
|
||||
|
||||
test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
|
||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
|
||||
await openLibrary(page);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.locator('.pm-editor-result').click();
|
||||
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
|
||||
await expect(page.locator('.pm-editor-library img, .pm-editor-library svg, .pm-editor-library script')).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(malicious);
|
||||
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
|
||||
});
|
||||
|
||||
async function deferLibraryRequests(page) {
|
||||
await page.evaluate(()=>{
|
||||
const originalFetch=window.fetch;
|
||||
window.libraryRequests=[];
|
||||
window.fetch=(url,options)=>{
|
||||
if(!/\/prompt_manager\/(search|recent)\?/.test(String(url))) return originalFetch(url,options);
|
||||
return new Promise(resolve=>window.libraryRequests.push({url:String(url),signal:options.signal,resolve}));
|
||||
};
|
||||
window.completeLibraryRequest=(index,text)=>libraryRequests[index].resolve(new Response(JSON.stringify({success:true,results:[{id:index,text}]}),{status:200,headers:{'Content-Type':'application/json'}}));
|
||||
});
|
||||
}
|
||||
|
||||
test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await openLibrary(page);
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
expect(await page.evaluate(()=>libraryRequests[0].signal.aborted)).toBe(true);
|
||||
await page.evaluate(()=>completeLibraryRequest(1,'Latest recent'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
|
||||
await page.evaluate(()=>completeLibraryRequest(0,'Stale search'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('latest search');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
expect(await page.evaluate(()=>libraryRequests[2].signal.aborted)).toBe(true);
|
||||
await page.evaluate(()=>completeLibraryRequest(3,'Latest search'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
|
||||
await page.evaluate(()=>completeLibraryRequest(2,'Stale recent'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
|
||||
});
|
||||
|
||||
test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await openLibrary(page);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
const removed=await page.evaluate(()=>{
|
||||
node.onRemoved();
|
||||
document.body.style.setProperty('--bg-color','#ffffff');
|
||||
fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));
|
||||
return {requestAborted:libraryRequests[0].signal.aborted,scopeAborted:node._focusedScope.signal.aborted};
|
||||
});
|
||||
expect(removed).toEqual({requestAborted:true,scopeAborted:true});
|
||||
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
if (isSplit) {
|
||||
const count=await page.evaluate(()=>libraryRequests.length);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
expect(await page.evaluate(()=>libraryRequests.length)).toBe(count);
|
||||
await expect(page.locator('.pm-editor-library')).toBeVisible();
|
||||
} else {
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => {
|
||||
await page.setViewportSize({width:1000,height:1000});
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
|
||||
const prepend=page.getByLabel('Prepend',{exact:true});
|
||||
const append=page.getByLabel('Append',{exact:true});
|
||||
let first=await prepend.boundingBox(),second=await append.boundingBox();
|
||||
expect(Math.abs(first.y-second.y)).toBeLessThan(2);
|
||||
expect(second.x).toBeGreaterThan(first.x+first.width);
|
||||
await page.evaluate(()=>{node._focusedView.element.style.width='300px';});
|
||||
await expect.poll(async()=>{const a=await prepend.boundingBox(),b=await append.boundingBox();return b.y>a.y+a.height;}).toBe(true);
|
||||
expect(await page.locator('.pm-focused').evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||
const dark=await prepend.evaluate(element=>getComputedStyle(element).backgroundColor);
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#ffffff'));
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','light');
|
||||
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).not.toBe(dark);
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222222'));
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
|
||||
});
|
||||
|
||||
test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => {
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
|
||||
await page.getByLabel('Append',{exact:true}).fill(' detail ');
|
||||
await page.getByText('Composed text preview',{exact:true}).click();
|
||||
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
|
||||
});
|
||||
|
||||
test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => {
|
||||
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
|
||||
});
|
||||
|
||||
test(`${variant} expands its main editor when the node gets taller`,async({page})=>{
|
||||
await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit);
|
||||
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
|
||||
expect(editor.height).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
test(`${variant} respects the existing Web UI display preference`,async({page})=>{
|
||||
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
|
||||
await page.evaluate(()=>{
|
||||
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
|
||||
window.open=(...args)=>{window.openArgs=args;return {};};
|
||||
});
|
||||
await page.evaluate(()=>node._focusedSettingsReady);
|
||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
|
||||
});
|
||||
|
||||
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
|
||||
await page.evaluate(()=>{window.open=()=>null;});
|
||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
|
||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||
});
|
||||
|
||||
if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => {
|
||||
await page.setViewportSize({width:1200,height:1000});
|
||||
const root=page.locator('.pm-split');
|
||||
await expect(root).toHaveAttribute('aria-label','Split prompt editor');
|
||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0);
|
||||
await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';});
|
||||
const compose=root.locator('.pm-editor-workspace > .pm-editor-compose');
|
||||
const library=root.locator('.pm-editor-workspace > .pm-editor-library');
|
||||
await expect(compose.locator('.pm-editor-main')).toBeVisible();
|
||||
let a=await compose.boundingBox(),b=await library.boundingBox();
|
||||
expect(Math.abs(a.y-b.y)).toBeLessThan(2);
|
||||
expect(b.x).toBeGreaterThanOrEqual(a.x+a.width);
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}}));
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library');
|
||||
await expect(library).toBeVisible();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library');
|
||||
await root.evaluate(element=>{element.style.width='340px';});
|
||||
await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true);
|
||||
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library');
|
||||
await expect(library).toBeVisible();
|
||||
});
|
||||
if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => {
|
||||
await page.setViewportSize({width:1200,height:1000});
|
||||
const root=page.locator('.pm-split');
|
||||
await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';});
|
||||
const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110));
|
||||
const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']}));
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}}));
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
const results=root.locator('.pm-editor-results');
|
||||
await expect(results.locator('.pm-editor-result')).toHaveCount(20);
|
||||
const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height));
|
||||
expect(rowHeights.every(height=>height>0)).toBe(true);
|
||||
const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
|
||||
expect.soft(resultSize.height).toBeLessThanOrEqual(300);
|
||||
expect(resultSize.scroll).toBeGreaterThan(resultSize.client);
|
||||
await results.locator('.pm-editor-result').first().click();
|
||||
const preview=root.locator('.pm-editor-selection > .pm-editor-preview');
|
||||
await expect(preview).toHaveText(longPrompt);
|
||||
const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
|
||||
expect.soft(previewSize.height).toBeLessThanOrEqual(180);
|
||||
expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client);
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt);
|
||||
const bounds=await root.boundingBox();
|
||||
for (const selector of ['.pm-editor-compose','.pm-editor-library']) {
|
||||
const pane=await root.locator(selector).boundingBox();
|
||||
expect(pane.y).toBeGreaterThanOrEqual(bounds.y);
|
||||
expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height);
|
||||
}
|
||||
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
const pair={id:4,positive_text:'Library positive',negative_text:'Library negative',category:'Portrait',tags:['studio']};
|
||||
test.beforeEach(async({page,browserName})=>{
|
||||
await startCoverage(page,browserName);
|
||||
page.pairErrors=[];page.on('pageerror',error=>page.pairErrors.push(error.message));
|
||||
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
|
||||
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
|
||||
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
|
||||
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[pair],total:1}}));
|
||||
await page.goto('/prompt_manager/test');
|
||||
await page.evaluate(async()=>{
|
||||
document.body.replaceChildren();
|
||||
const settings=new EventTarget();settings.getSettingValue=()=> 'dark';
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[1100,700];}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
setSize(size){this.size=size;}setDirtyCanvas(){}
|
||||
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
|
||||
configure(data){this.widgets.forEach((widget,index)=>widget.value=data.widgets_values[index]);this.properties={...data.properties};}
|
||||
}
|
||||
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerPair'},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated?.();fixtureApp.graph._nodes=[node];
|
||||
});
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.pairErrors).toEqual([]);});
|
||||
|
||||
async function selectPair(page){await page.getByRole('button',{name:'Recent',exact:true}).click();await page.locator('.pm-editor-result').click();}
|
||||
async function draft(page,positive='Draft positive',negative='Draft negative'){await page.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(positive);await page.getByRole('textbox',{name:'Negative prompt',exact:true}).fill(negative);}
|
||||
|
||||
test('paired editor previews both texts before explicitly replacing both',async({page})=>{
|
||||
await expect(page.locator('.pm-pair')).toBeVisible();
|
||||
await draft(page);
|
||||
await selectPair(page);
|
||||
await expect(page.locator('.pm-editor-selection')).toContainText('Library positive');
|
||||
await expect(page.locator('.pm-editor-selection')).toContainText('Library negative');
|
||||
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Draft positive');
|
||||
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Draft negative');
|
||||
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
|
||||
await page.getByRole('button',{name:'Use pair',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(pair.positive_text);
|
||||
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
|
||||
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:pair.positive_text,negative_text:pair.negative_text,category:'',tags:''});
|
||||
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
|
||||
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
|
||||
});
|
||||
|
||||
test('paired editor sizes both editors equally and composes positive text only',async({page},testInfo)=>{
|
||||
await draft(page);
|
||||
const positive=page.getByRole('textbox',{name:'Positive prompt',exact:true});const negative=page.getByRole('textbox',{name:'Negative prompt',exact:true});
|
||||
const [p,n]=await Promise.all([positive.boundingBox(),negative.boundingBox()]);expect(Math.abs(p.height-n.height)).toBeLessThan(1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.getByRole('textbox',{name:'Prepend',exact:true}).fill('trigger');
|
||||
await page.getByRole('textbox',{name:'Append',exact:true}).fill('detail');
|
||||
await page.getByText('Composed text preview',{exact:true}).click();
|
||||
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Draft positive detail');
|
||||
await expect(negative).toHaveValue('Draft negative');
|
||||
await page.screenshot({path:testInfo.outputPath('pair-editor.png')});
|
||||
});
|
||||
|
||||
for(const field of ['text','negative_text']) test(`paired loading is atomic when ${field} is connected`,async({page})=>{
|
||||
await draft(page);
|
||||
await selectPair(page);
|
||||
await page.evaluate(field=>{node.inputs.push({name:field,link:8});node.onConnectionsChange();},field);
|
||||
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeDisabled();
|
||||
await page.getByRole('button',{name:'Use pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
|
||||
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative'});
|
||||
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==8);node.onConnectionsChange();});
|
||||
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeEnabled();
|
||||
});
|
||||
|
||||
test('save pair accepts empty negatives, recovers after errors and never clears the draft',async({page})=>{
|
||||
let attempts=0;const bodies=[];
|
||||
await page.route('**/prompt_manager/pairs',route=>{bodies.push(route.request().postDataJSON());return ++attempts===1?route.fulfill({status:503,json:{success:false,error:'Temporary save error'}}):route.fulfill({json:{success:true,pair:{...pair,negative_text:''}}});});
|
||||
await draft(page,'Save this','');
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Temporary save error');
|
||||
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Save this');
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
|
||||
expect(bodies).toEqual([{positive_text:'Save this',negative_text:'',category:'',tags:[]},{positive_text:'Save this',negative_text:'',category:'',tags:[]}]);
|
||||
});
|
||||
|
||||
test('pair filters use repeated tags and removal cleans up',async({page})=>{
|
||||
const urls=[];await page.route('**/prompt_manager/pairs?**',route=>{urls.push(new URL(route.request().url()));return route.fulfill({json:{success:true,pairs:[pair]}});});
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio');
|
||||
await page.getByLabel('Filter category',{exact:true}).fill('Portrait');
|
||||
await page.getByLabel('Filter tags',{exact:true}).fill('film, studio, ');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();await expect(page.locator('.pm-editor-result')).toBeVisible();
|
||||
expect(urls[0].searchParams.get('category')).toBe('Portrait');expect(urls[0].searchParams.getAll('tag')).toEqual(['studio','film']);
|
||||
await page.evaluate(()=>node.onRemoved());await expect(page.locator('.ca-completions')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('validating and saving prevents duplicate submissions while preserving pair metadata and drafts',async({page})=>{
|
||||
let release;let requests=0;
|
||||
await page.route('**/prompt_manager/pairs',async route=>{requests++;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair:{...pair,category:'Original category',tags:['original-tag']}}});});
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('alert')).toContainText('positive');expect(requests).toBe(0);
|
||||
await draft(page);
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Save pair',exact:true})).toBeDisabled();
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
|
||||
expect(requests).toBe(1);release();
|
||||
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
|
||||
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative',category:'',tags:''});
|
||||
});
|
||||
|
||||
test('a superseded library response never replaces the latest pair selection',async({page})=>{
|
||||
let release;let started=false;
|
||||
await page.route('**/prompt_manager/pairs?**',async route=>{
|
||||
if(new URL(route.request().url()).searchParams.get('text')==='old'){started=true;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pairs:[{...pair,positive_text:'Stale positive'}]}}).catch(()=>{});}
|
||||
else await route.fulfill({json:{success:true,pairs:[pair]}});
|
||||
});
|
||||
const search=page.getByRole('searchbox',{name:'Search saved prompts'});
|
||||
await search.fill('old');await page.getByRole('button',{name:'Search',exact:true}).click();await expect.poll(()=>started).toBe(true);
|
||||
await search.fill('new');await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Library positive');release();
|
||||
await expect(page.locator('.pm-editor-result')).not.toContainText('Stale positive');
|
||||
});
|
||||
|
||||
test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{
|
||||
await draft(page);
|
||||
const args=await page.evaluate(async()=>{
|
||||
await fixtureApp.extension.setup();
|
||||
const {api}=await import('/scripts/api.js');
|
||||
const output={1:{inputs:{text:'stale',negative_text:'stale'}}};
|
||||
const first=await api.queuePrompt(7,{output,workflow:{name:'workflow'}},['branch'],{preview:'latent'});
|
||||
node.inputs.push({name:'negative_text',link:17});
|
||||
const linked={1:{inputs:{text:'old',negative_text:['17',0]}}};
|
||||
const second=await api.queuePrompt(8,{output:linked,workflow:{}},['another']);
|
||||
return {first,second};
|
||||
});
|
||||
expect(args.first).toEqual([7,{output:{1:{inputs:{text:'Draft positive',negative_text:'Draft negative'}}},workflow:{name:'workflow'}},['branch'],{preview:'latent'}]);
|
||||
expect(args.second[1].output[1].inputs).toEqual({text:'Draft positive',negative_text:['17',0]});
|
||||
});
|
||||
|
||||
test('paired editors stack at narrow widths while keeping equal editing areas',async({page})=>{
|
||||
await page.locator('.fixture-dom-widget').evaluate(host=>host.style.width='560px');
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>page.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThanOrEqual(p.y+p.height);expect(Math.abs(p.width-n.width)).toBeLessThan(1);expect(Math.abs(p.height-n.height)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test('removal aborts an in-flight pair save and restores host-owned textarea references',async({page})=>{
|
||||
await draft(page);
|
||||
const state=await page.evaluate(async()=>{
|
||||
const previous=window.fetch;let signal;
|
||||
window.fetch=(url,options)=>{if(String(url).endsWith('/pairs')){signal=options.signal;return new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}));}return previous(url,options);};
|
||||
const visible=node.widgets.find(w=>w.name==='negative_text').inputEl;
|
||||
[...document.querySelectorAll('button')].find(button=>button.textContent==='Save pair').click();
|
||||
node.onRemoved();
|
||||
return {aborted:signal.aborted,restored:node.widgets.find(w=>w.name==='negative_text').inputEl!==visible};
|
||||
});
|
||||
expect(state).toEqual({aborted:true,restored:true});
|
||||
});
|
||||
|
||||
test('actual pair saves coexist with resetting the disposable browser fixture',async({page,request})=>{
|
||||
await draft(page,'Actual browser pair','Actual negative');await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
|
||||
const response=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:'Actual browser pair',negative_text:'Actual negative'})]));
|
||||
const reset=await request.post('/__test__/reset');expect(reset.ok()).toBe(true);
|
||||
const after=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect((await after.json()).pairs).toHaveLength(0);
|
||||
});
|
||||
|
||||
for(const field of ['category','tags'])test(`manual pair save waits for execution when ${field} comes from an upstream link`,async({page})=>{
|
||||
let requests=0;await page.route('**/prompt_manager/pairs',route=>{requests++;return route.fulfill({json:{success:true,pair}});});await draft(page);
|
||||
await page.evaluate(field=>{node.inputs.push({name:field,link:19});node.onConnectionsChange();},field);
|
||||
const save=page.getByRole('button',{name:'Save pair',exact:true});await expect(save).toBeDisabled();await expect(page.getByRole('status').filter({hasText:'Run the node to save connected values.'})).toBeVisible();
|
||||
await save.evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));expect(requests).toBe(0);
|
||||
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==19);node.onConnectionsChange();});await expect(save).toBeEnabled();await save.click();await expect.poll(()=>requests).toBe(1);
|
||||
});
|
||||
|
||||
for(const field of ['positive','negative','category','tags'])test(`save completion identifies an earlier draft after editing ${field}`,async({page})=>{
|
||||
let release;await page.route('**/prompt_manager/pairs',async route=>{await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair}});});await draft(page);
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect.poll(()=>Boolean(release)).toBe(true);
|
||||
const input=field==='positive'?page.getByRole('textbox',{name:'Positive prompt',exact:true}):field==='negative'?page.getByRole('textbox',{name:'Negative prompt',exact:true}):page.getByRole('combobox',{name:field==='category'?'Category':'Tags',exact:true});
|
||||
await input.fill(`Changed ${field}`);release();await expect(page.getByRole('status').filter({hasText:'Earlier draft saved. Current changes are not saved yet.'})).toBeVisible();await expect(input).toHaveValue(`Changed ${field}`);
|
||||
});
|
||||
|
||||
test('pair library rows distinguish negative variants and explicitly show empty negatives as text',async({page})=>{
|
||||
const longNegative='Variant one '+ 'x'.repeat(200);
|
||||
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[{...pair,negative_text:longNegative},{...pair,id:5,negative_text:'Variant two'},{...pair,id:6,negative_text:''},{...pair,id:7,negative_text:'<img src=x onerror=alert(1)>'}]}}));
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();const rows=page.locator('.pm-editor-result');await expect(rows).toHaveCount(4);await expect(rows.nth(0)).toContainText('Negative: Variant one');await expect(rows.nth(1)).toContainText('Negative: Variant two');await expect(rows.nth(2)).toContainText('Negative: (empty)');await expect(rows.nth(3)).toContainText('Negative: <img src=x onerror=alert(1)>');await expect(rows.locator('img')).toHaveCount(0);await expect(rows.nth(0)).not.toContainText('x'.repeat(180));await expect(rows.nth(0).locator('span').nth(1)).toHaveAttribute('title',longNegative);
|
||||
});
|
||||
@@ -80,7 +80,15 @@ def create_app(root: Path) -> web.Application:
|
||||
|
||||
def seed(extra_images: int = 0) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
|
||||
for table in (
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompts",
|
||||
):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
|
||||
|
||||
async function addEditor(page, type) {
|
||||
await page.waitForFunction(type => Boolean(globalThis.LiteGraph?.registered_node_types?.[type]), type);
|
||||
return page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(type);
|
||||
if (!node) throw new Error(`${type} must be registered by the real host`);
|
||||
app.graph.add(node);
|
||||
node.setSize([type === 'PromptManagerSplit' ? 1100 : 680, 1050]);
|
||||
app.canvas.ds.scale = .8;
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
}, type);
|
||||
}
|
||||
|
||||
async function model(page, id) {
|
||||
return page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Object.fromEntries(app.graph.getNodeById(id).widgets.map(widget => [widget.name, widget.value]));
|
||||
}, id);
|
||||
}
|
||||
|
||||
async function seed(request, text, category = 'Focused host tests', tags = ['focused-host']) {
|
||||
const response = await request.post('/prompt_manager/save', { data: { text, category, tags } });
|
||||
expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).success).toBe(true);
|
||||
}
|
||||
|
||||
async function showLibrary(page) {
|
||||
const button = page.locator('.pm-focused').getByRole('button', { name: 'Browse library', exact: true });
|
||||
if (await button.count()) await button.click();
|
||||
}
|
||||
|
||||
async function browse(page, text) {
|
||||
const root = page.locator('.pm-focused');
|
||||
await showLibrary(page);
|
||||
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||
return root;
|
||||
}
|
||||
|
||||
for (const [label, type] of [['Focused', 'PromptManagerFocused'], ['Split', 'PromptManagerSplit']]) {
|
||||
test.describe(label, () => {
|
||||
test(`${label} definition preserves Classic encoding and exposes composition STRING sockets`, async ({ request }) => {
|
||||
const definitions = await (await request.get('/object_info')).json();
|
||||
const focused = definitions[type];
|
||||
expect(focused).toBeDefined();
|
||||
expect(focused.display_name).toBe(`Prompt Manager (${label})`);
|
||||
expect(focused.output).toEqual(definitions.PromptManager.output);
|
||||
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
|
||||
for (const name of ['prepend_text', 'append_text']) {
|
||||
expect(focused.input.optional[name][0]).toBe('STRING');
|
||||
expect(focused.input.optional[name][1]).toMatchObject({ forceInput: true, default: '' });
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label} preview, explicit load, composition, renderer changes and workflow reload preserve state`, async ({ page, request }, testInfo) => {
|
||||
const text = `Focused selected ${testInfo.project.name}`;
|
||||
await seed(request, text);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
||||
await expect(root).toBeVisible();
|
||||
expect(await editor.evaluate(async (element, id) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl === element;
|
||||
}, id)).toBe(true);
|
||||
await editor.fill('Original editing text');
|
||||
await browse(page, text);
|
||||
await expect(editor).toHaveValue('Original editing text');
|
||||
expect((await model(page, id)).text).toBe('Original editing text');
|
||||
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
|
||||
await expect(editor).toHaveValue(text);
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host' });
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('LoRA trigger');
|
||||
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('high detail');
|
||||
await root.getByText('Composed text preview', { exact: true }).click();
|
||||
await expect(root).toContainText(`LoRA trigger ${text} high detail`);
|
||||
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(root).toHaveCount(1);
|
||||
await expect(editor).toHaveValue(text);
|
||||
}
|
||||
const queued = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||
await expect(editor).toHaveValue(text);
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-reloaded.png`) });
|
||||
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
|
||||
await expect(root).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`${label} search preview respects metadata opt-out`, async ({ page, request }, testInfo) => {
|
||||
const text = `Focused search ${testInfo.project.name}`;
|
||||
await seed(request, text, 'Incoming category', ['incoming-tag']);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
|
||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
|
||||
await showLibrary(page);
|
||||
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
|
||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||
await root.getByRole('switch', { name: 'Load metadata', exact: true }).uncheck();
|
||||
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
|
||||
});
|
||||
|
||||
test(`${label} connected STRING inputs remain linked through UI actions and reload`, async ({ page, request }, testInfo) => {
|
||||
const text = `Connected library ${testInfo.project.name}`;
|
||||
await seed(request, text);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('Local prepend');
|
||||
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('Local append');
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
const links = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const { convertToInput } = await import('/extensions/core/widgetInputs.js');
|
||||
const target = app.graph.getNodeById(id);
|
||||
convertToInput(target, target.widgets.find(widget => widget.name === 'text'));
|
||||
const result = {};
|
||||
for (const name of ['text', 'prepend_text', 'append_text']) {
|
||||
const source = LiteGraph.createNode('PrimitiveString');
|
||||
app.graph.add(source);
|
||||
source.pos = [target.pos[0] - 360, target.pos[1] + Object.keys(result).length * 150];
|
||||
source.widgets.find(widget => widget.name === 'value').value = `Connected ${name}`;
|
||||
const slot = target.inputs.findIndex(input => input.name === name);
|
||||
if (slot < 0) throw new Error(`Missing ${name} input socket`);
|
||||
source.connect(0, target, slot);
|
||||
result[name] = [String(source.id), 0];
|
||||
}
|
||||
app.canvas.centerOnNode(target);
|
||||
return result;
|
||||
}, id);
|
||||
await browse(page, text);
|
||||
await expect(root.getByRole('textbox', { name: 'Main prompt', exact: true })).toBeHidden();
|
||||
await expect(root.getByRole('button', { name: 'Use prompt', exact: true })).toBeDisabled();
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
for (const name of ['Prepend', 'Append']) await expect(root.getByRole('textbox', { name, exact: true })).toBeHidden();
|
||||
const guardedValues = await model(page, id);
|
||||
await root.locator('textarea, input').evaluateAll(inputs => {
|
||||
for (const input of inputs) {
|
||||
if (!input.hidden && !input.closest('[hidden]')) continue;
|
||||
input.value = 'Attempt to replace upstream text';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
expect(await model(page, id)).toMatchObject(guardedValues);
|
||||
for (let reload = 0; reload < 2; reload++) {
|
||||
const inputs = await page.evaluate(async ({ id, reload }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
if (reload) await app.loadGraphData(app.graph.serialize());
|
||||
return (await app.graphToPrompt()).output[String(id)].inputs;
|
||||
}, { id, reload });
|
||||
expect(inputs).toMatchObject(links);
|
||||
}
|
||||
await expect(root.getByRole('button', { name: 'Use prompt', exact: true, includeHidden: true })).toBeDisabled();
|
||||
const disconnected = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
for (const name of ['text', 'prepend_text', 'append_text']) node.disconnectInput(node.inputs.findIndex(input => input.name === name));
|
||||
return (await app.graphToPrompt()).output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(disconnected).toMatchObject({ text: 'Local main', prepend_text: 'Local prepend', append_text: 'Local append' });
|
||||
for (const [name, value] of [['Main prompt', 'Local main'], ['Prepend', 'Local prepend'], ['Append', 'Local append']]) {
|
||||
const input = page.getByRole('textbox', { name, exact: true });
|
||||
await expect(input).toHaveCount(1);
|
||||
await expect(input).toBeEditable();
|
||||
await expect(input).toHaveValue(value);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-disconnected.png`) });
|
||||
});
|
||||
|
||||
test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, testInfo) => {
|
||||
await addEditor(page, type);
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
||||
}, palette);
|
||||
const root = page.locator('.pm-focused');
|
||||
await expect(root).toHaveAttribute('data-ca-theme', palette);
|
||||
if (label === 'Split') {
|
||||
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
|
||||
await expect(root.getByRole('button', { name: 'Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
} else {
|
||||
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-${palette}.png`) });
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
|
||||
await addEditor(page, type);
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||
await expect(popup.locator('body')).toContainText('Prompt');
|
||||
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
|
||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test(`${label} metadata completion uses saved categories and tags across renderers and reload`, async ({ page, request }, testInfo) => {
|
||||
const category = `Host completion category ${testInfo.project.name}`;
|
||||
const tag = `host-completion-${testInfo.project.name}`;
|
||||
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
const categoryInput = root.getByRole('combobox', { name: 'Category', exact: true });
|
||||
const tagInput = root.getByRole('combobox', { name: 'Tags', exact: true });
|
||||
await categoryInput.fill(category.slice(0, -3));
|
||||
const categories = page.getByRole('listbox', { name: 'Saved categories', exact: true });
|
||||
await expect(categories.getByRole('option', { name: category, exact: true })).toBeVisible();
|
||||
await categoryInput.press('ArrowDown');
|
||||
await expect(categoryInput).toHaveAttribute('aria-activedescendant', /.+/);
|
||||
await categoryInput.press('Enter');
|
||||
await expect(categoryInput).toHaveValue(category);
|
||||
await expect(categoryInput).toHaveAttribute('aria-expanded', 'false');
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
||||
}, palette);
|
||||
await tagInput.fill(`keep, ${tag.slice(0, -3)}`);
|
||||
const tags = page.getByRole('listbox', { name: 'Saved tags', exact: true });
|
||||
const option = tags.getByRole('option', { name: tag, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await expect(tags).toHaveAttribute('data-ca-theme', palette);
|
||||
const bounds = await tags.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-metadata-${palette}.png`) });
|
||||
await option.click();
|
||||
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
|
||||
}
|
||||
const expected = { text: 'Autocomplete workflow', category, tags: `keep, ${tag}, ` };
|
||||
await expect.poll(() => model(page, id)).toMatchObject(expected);
|
||||
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(categoryInput).toHaveValue(category);
|
||||
await expect(tagInput).toHaveValue(expected.tags);
|
||||
}
|
||||
const queued = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(queued).toMatchObject(expected);
|
||||
await expect(categoryInput).toHaveValue(category);
|
||||
await expect(tagInput).toHaveValue(expected.tags);
|
||||
await categoryInput.fill(category.slice(0, -3));
|
||||
await expect(categories).toBeVisible();
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph.getNodeById(id));
|
||||
}, id);
|
||||
await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`${label} library autocomplete filters saved prompts without editing draft metadata`, async ({ page, request }, testInfo) => {
|
||||
const category = `Library category ${testInfo.project.name}`;
|
||||
const tag = `library-filter-${testInfo.project.name}`;
|
||||
const matchingText = `Matching library prompt ${testInfo.project.name}`;
|
||||
await seed(request, matchingText, category, [tag]);
|
||||
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
|
||||
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
|
||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
|
||||
await showLibrary(page);
|
||||
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
|
||||
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
|
||||
await filterCategory.fill(category.slice(0, -3));
|
||||
await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible();
|
||||
await filterCategory.press('ArrowDown');
|
||||
await filterCategory.press('Enter');
|
||||
await expect(filterCategory).toHaveValue(category);
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
||||
}, palette);
|
||||
await filterTags.fill(tag.slice(0, -3));
|
||||
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-library-filters-${palette}.png`) });
|
||||
await option.click();
|
||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||
}
|
||||
const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' };
|
||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
const results = root.locator('.pm-editor-results').getByRole('button');
|
||||
await expect(results).toHaveCount(1);
|
||||
await expect(results.first()).toContainText(matchingText);
|
||||
await results.first().click();
|
||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(filterCategory).toHaveValue(category);
|
||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||
}
|
||||
const queued = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(queued).toMatchObject(draft);
|
||||
await expect(filterCategory).toHaveValue(category);
|
||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await expect(results).toHaveCount(1);
|
||||
await expect(results.first()).toContainText(matchingText);
|
||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||
});
|
||||
|
||||
if (label === 'Split') {
|
||||
test('Split keeps its library visible beside the editor and stacks narrow nodes', async ({ page, request }, testInfo) => {
|
||||
for (let index = 0; index < 21; index++) {
|
||||
await seed(request, `Split layout recent ${testInfo.project.name} ${index}`);
|
||||
}
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-split');
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
node.setSize([900, 650]);
|
||||
app.canvas.centerOnNode(node);
|
||||
}, id);
|
||||
await expect(root).toHaveAttribute('aria-label', 'Split prompt editor');
|
||||
const savedProperties = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph.getNodeById(id).serialize().properties;
|
||||
}, id);
|
||||
expect(savedProperties).toHaveProperty('splitUI');
|
||||
expect(savedProperties).not.toHaveProperty('focusedUI');
|
||||
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
|
||||
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
||||
const library = root.getByRole('region', { name: 'Prompt library', exact: true });
|
||||
await expect(library).toBeVisible();
|
||||
const captureGeometry = async phase => {
|
||||
const geometry = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
const root = node._focusedView.element;
|
||||
const bounds = selector => root.querySelector(selector)?.getBoundingClientRect().toJSON();
|
||||
const ancestors = [];
|
||||
for (let element = root; element && element !== document.body; element = element.parentElement) {
|
||||
const style = getComputedStyle(element);
|
||||
ancestors.push({ tag: element.tagName, className: element.className, bounds: element.getBoundingClientRect().toJSON(), inlineStyle: element.style.cssText, computed: Object.fromEntries(['height', 'min-height', 'max-height', 'display', 'overflow', 'flex', 'contain', 'position'].map(name => [name, style.getPropertyValue(name)])) });
|
||||
}
|
||||
return { nodeSize: [...node.size], ancestors, root: root.getBoundingClientRect().toJSON(), library: bounds('.pm-editor-library'), results: bounds('.pm-editor-results'), use: bounds('.pm-editor-selection button'), footer: bounds(':scope > .pm-editor-status') };
|
||||
}, id);
|
||||
const path = testInfo.outputPath(`split-${phase}-geometry.json`);
|
||||
await writeFile(path, JSON.stringify(geometry, null, 2));
|
||||
await testInfo.attach(`split-${phase}-geometry.json`, { path, contentType: 'application/json' });
|
||||
return geometry;
|
||||
};
|
||||
const beforeRecent = await captureGeometry('before-recent');
|
||||
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
||||
const results = library.locator('.pm-editor-results').getByRole('button');
|
||||
await expect(results).toHaveCount(20);
|
||||
const promptText = results.first().locator('span').first();
|
||||
const selectedText = await promptText.innerText();
|
||||
await results.first().click();
|
||||
const use = root.getByRole('button', { name: 'Use prompt', exact: true });
|
||||
const afterSelection = await captureGeometry('after-selection');
|
||||
await page.screenshot({ path: testInfo.outputPath('split-default-selected.png') });
|
||||
expect.soft((await promptText.boundingBox())?.height || 0).toBeGreaterThan(10);
|
||||
expect.soft(afterSelection.root.height).toBeLessThanOrEqual(beforeRecent.root.height + 1);
|
||||
await use.click();
|
||||
await expect(editor).toHaveValue(selectedText);
|
||||
await expect.poll(async () => {
|
||||
const main = await editor.boundingBox();
|
||||
const browser = await library.boundingBox();
|
||||
return browser.x >= main.x + main.width;
|
||||
}).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath('split-wide.png') });
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
node.setSize([480, 1050]);
|
||||
app.canvas.centerOnNode(node);
|
||||
}, id);
|
||||
await expect.poll(async () => {
|
||||
const main = await editor.boundingBox();
|
||||
const browser = await library.boundingBox();
|
||||
return browser.y >= main.y + main.height;
|
||||
}).toBe(true);
|
||||
await expect(library).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('split-narrow.png') });
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) {
|
||||
test.beforeEach(async ({ page, context, request }, testInfo) => {
|
||||
const response = await request.get('/system_stats');
|
||||
expect(response.ok()).toBe(true);
|
||||
const info = (await response.json()).system;
|
||||
expect(info.comfyui_version).toBe(runtime.serverVersion);
|
||||
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
|
||||
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
|
||||
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
|
||||
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
|
||||
page.hostErrors = [];
|
||||
page.on('pageerror', error => page.hostErrors.push(error.message));
|
||||
await context.route('**/*', async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
|
||||
if (path === '/settings' && request.method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ json: { ...await response.json(),
|
||||
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
||||
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
||||
'Comfy.Notification.ShowVersionUpdates': false } });
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async expectedTypes => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return !!app.graph && expectedTypes.every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
|
||||
}, expectedTypes);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
expect(page.hostErrors).toEqual([]);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test,['PromptManagerPair']);
|
||||
async function addPair(page){return page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id;});}
|
||||
async function seed(request,positive,negative){const response=await request.post('/prompt_manager/pairs',{data:{positive_text:positive,negative_text:negative,category:'Paired host tests',tags:['pair-host']}});expect(response.ok()).toBe(true);return (await response.json()).pair;}
|
||||
async function values(page,id){return page.evaluate(async id=>{const {app}=await import('/scripts/app.js');return Object.fromEntries(app.graph.getNodeById(id).widgets.map(w=>[w.name,w.value]));},id);}
|
||||
async function select(page,text){const root=page.locator('.pm-pair');await root.getByRole('button',{name:'Recent',exact:true}).click();await root.locator('.pm-editor-result').filter({has:page.getByText(text,{exact:true})}).click();return root;}
|
||||
|
||||
test('paired definition exposes both conditioning/text outputs and positive-only composition',async({request})=>{
|
||||
const definitions=await(await request.get('/object_info')).json();const pair=definitions.PromptManagerPair;
|
||||
expect(pair).toBeDefined();expect(pair.output).toEqual(['CONDITIONING','CONDITIONING','STRING','STRING']);
|
||||
expect(pair.input.required.text[0]).toBe('STRING');expect(pair.input.required.negative_text[0]).toBe('STRING');expect(pair.input.required.clip[0]).toBe('CLIP');
|
||||
for(const name of ['prepend_text','append_text'])expect(pair.input.optional[name]).toMatchObject(['STRING',{default:'',forceInput:true}]);
|
||||
expect(pair.input.optional.negative_prepend_text).toBeUndefined();
|
||||
});
|
||||
|
||||
test('paired library previews both, atomically loads, queues and survives renderer switches and reload',async({page,request},testInfo)=>{
|
||||
const positive=`Pair positive ${testInfo.project.name}`;const negative=`Pair negative ${testInfo.project.name}`;await seed(request,positive,negative);const id=await addPair(page);const root=page.locator('.pm-pair');
|
||||
const pos=root.getByRole('textbox',{name:'Positive prompt',exact:true});const neg=root.getByRole('textbox',{name:'Negative prompt',exact:true});
|
||||
await pos.fill('Local positive');await neg.fill('Local negative');
|
||||
for(const [name,field]of [['text',pos],['negative_text',neg]])expect(await field.evaluate(async(element,{id,name})=>{const {app}=await import('/scripts/app.js');return app.graph.getNodeById(id).widgets.find(w=>w.name===name).inputEl===element;},{id,name})).toBe(true);
|
||||
await select(page,positive);await expect(root.locator('.pm-editor-selection')).toContainText(negative);await expect(pos).toHaveValue('Local positive');await expect(neg).toHaveValue('Local negative');
|
||||
await root.getByRole('button',{name:'Use pair',exact:true}).click();await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
|
||||
await root.getByText('Composition · prepend / append',{exact:true}).click();await root.getByRole('textbox',{name:'Prepend',exact:true}).fill('trigger');await root.getByRole('textbox',{name:'Append',exact:true}).fill('detail');
|
||||
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]){
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
|
||||
}
|
||||
const queued=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const inputs=(await app.graphToPrompt()).output[String(id)].inputs;await app.loadGraphData(app.graph.serialize());return inputs;},id);
|
||||
expect(queued).toMatchObject({text:positive,negative_text:negative,prepend_text:'trigger',append_text:'detail'});await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
|
||||
await page.screenshot({path:testInfo.outputPath('pair-reloaded.png')});
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(id));},id);await expect(root).toHaveCount(0);await expect(page.locator('.ca-completions')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('paired real STRING connections stay linked and restore both local editors after disconnect',async({page,request},testInfo)=>{
|
||||
const positive=`Connected pair ${testInfo.project.name}`;await seed(request,positive,'Connected library negative');const id=await addPair(page);const root=page.locator('.pm-pair');
|
||||
await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Local positive');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Local negative');
|
||||
await root.getByText('Composition · prepend / append',{exact:true}).click();await root.getByRole('textbox',{name:'Prepend',exact:true}).fill('Local prepend');await root.getByRole('textbox',{name:'Append',exact:true}).fill('Local append');
|
||||
const links=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');const node=app.graph.getNodeById(id);for(const name of ['text','negative_text'])convertToInput(node,node.widgets.find(w=>w.name===name));const result={};for(const name of ['text','negative_text','prepend_text','append_text']){const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(w=>w.name==='value').value=`Connected ${name}`;source.connect(0,node,node.inputs.findIndex(i=>i.name===name));result[name]=[String(source.id),0];}app.canvas.centerOnNode(node);return result;},id);
|
||||
await select(page,positive);await expect(root.getByRole('button',{name:'Use pair',exact:true})).toBeDisabled();await expect(root.getByRole('button',{name:'Save pair',exact:true})).toBeDisabled();
|
||||
for(const name of ['Positive prompt','Negative prompt','Prepend','Append'])await expect(root.getByRole('textbox',{name,exact:true})).toBeHidden();
|
||||
const before=await values(page,id);await root.locator('textarea,input').evaluateAll(inputs=>{for(const input of inputs){if(!input.hidden&&!input.closest('[hidden]'))continue;input.value='Overwrite attempt';input.dispatchEvent(new Event('input',{bubbles:true}));}});expect(await values(page,id)).toMatchObject(before);
|
||||
for(const reload of [false,true]){const queued=await page.evaluate(async({id,reload})=>{const {app}=await import('/scripts/app.js');if(reload)await app.loadGraphData(app.graph.serialize());return(await app.graphToPrompt()).output[String(id)].inputs;},{id,reload});expect(queued).toMatchObject(links);}
|
||||
const local=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);for(const name of ['text','negative_text','prepend_text','append_text'])node.disconnectInput(node.inputs.findIndex(i=>i.name===name));return(await app.graphToPrompt()).output[String(id)].inputs;},id);
|
||||
expect(local).toMatchObject({text:'Local positive',negative_text:'Local negative',prepend_text:'Local prepend',append_text:'Local append'});
|
||||
for(const [name,value]of [['Positive prompt','Local positive'],['Negative prompt','Local negative'],['Prepend','Local prepend'],['Append','Local append']]){const field=root.getByRole('textbox',{name,exact:true});await expect(field).toHaveCount(1);await expect(field).toBeEditable();await expect(field).toHaveValue(value);}
|
||||
});
|
||||
|
||||
test('paired default layout uses equal editors and follows real host themes',async({page},testInfo)=>{
|
||||
await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Positive draft');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Negative draft');
|
||||
for(const palette of ['light','dark']){await page.evaluate(async palette=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.ColorPalette',palette);},palette);await expect(root).toHaveAttribute('data-ca-theme',palette);
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(Math.abs(p.width-n.width)).toBeLessThan(1);expect(Math.abs(p.height-n.height)).toBeLessThan(1);expect(p.height).toBeGreaterThan(100);
|
||||
await page.screenshot({path:testInfo.outputPath(`pair-${palette}.png`)});
|
||||
}
|
||||
});
|
||||
|
||||
test('paired explicit Save persists an empty negative through actual API and preserves local draft',async({page,request},testInfo)=>{
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();const response=await request.get(`/prompt_manager/pairs?text=${encodeURIComponent(text)}`);expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:text,negative_text:''})]));expect(await values(page,id)).toMatchObject({text,negative_text:''});
|
||||
});
|
||||
|
||||
test('paired library filters search both texts, persist on reload and keep editor metadata separate',async({page,request},testInfo)=>{
|
||||
const token=`paired-filter-${testInfo.project.name}`;await seed(request,`Matching ${token}`,`Negative ${token}`);const other=await request.post('/prompt_manager/pairs',{data:{positive_text:`Other ${token}`,negative_text:'different',category:'Other paired category',tags:['other-pair-tag']}});expect(other.ok()).toBe(true);
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Draft positive');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Draft negative');
|
||||
await root.getByText('Metadata · category / tags',{exact:true}).click();await root.getByRole('combobox',{name:'Category',exact:true}).fill('Keep category');await root.getByRole('combobox',{name:'Tags',exact:true}).fill('keep-tag');await root.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
await root.getByRole('searchbox',{name:'Search saved prompts'}).fill(token);await root.getByLabel('Filter category',{exact:true}).fill('Paired host tests');await root.getByLabel('Filter tags',{exact:true}).fill('pair-host, ');await root.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await expect(root.locator('.pm-editor-result')).toHaveCount(1);await root.locator('.pm-editor-result').click();await expect(root.locator('.pm-editor-selection')).toContainText(`Negative ${token}`);
|
||||
expect(await values(page,id)).toMatchObject({text:'Draft positive',negative_text:'Draft negative',category:'Keep category',tags:'keep-tag'});await root.getByRole('switch',{name:'Load metadata'}).uncheck();await root.getByRole('button',{name:'Use pair',exact:true}).click();expect(await values(page,id)).toMatchObject({text:`Matching ${token}`,negative_text:`Negative ${token}`,category:'Keep category',tags:'keep-tag'});
|
||||
await page.screenshot({path:testInfo.outputPath('pair-library-preview.png')});
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});await expect(root.getByLabel('Filter category',{exact:true})).toHaveValue('Paired host tests');await expect(root.getByLabel('Filter tags',{exact:true})).toHaveValue('pair-host, ');
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.setSize([560,1000]);app.canvas.centerOnNode(node);},id);
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThan(p.y+p.height-1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);await page.screenshot({path:testInfo.outputPath('pair-narrow.png')});
|
||||
});
|
||||
|
||||
for(const field of ['category','tags'])test(`manual Save pair guards real connected ${field} and resumes after disconnect`,async({page},testInfo)=>{
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(`Metadata link ${field} ${testInfo.project.name}`);
|
||||
let saves=0;await page.route('**/prompt_manager/pairs',async route=>{if(route.request().method()==='POST')saves++;await route.continue();});
|
||||
const link=await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');const target=app.graph.getNodeById(id);convertToInput(target,target.widgets.find(widget=>widget.name===field));const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(widget=>widget.name==='value').value='Connected metadata';source.connect(0,target,target.inputs.findIndex(input=>input.name===field));app.canvas.centerOnNode(target);return [String(source.id),0];},{id,field});
|
||||
const save=root.getByRole('button',{name:'Save pair',exact:true});await expect(save).toBeDisabled();await expect(root.getByRole('status').filter({hasText:'Run the node to save connected values.'})).toBeVisible();await save.evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));expect(saves).toBe(0);
|
||||
const input=await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs[field];},{id,field});expect(input).toEqual(link);
|
||||
await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');const target=app.graph.getNodeById(id);target.disconnectInput(target.inputs.findIndex(input=>input.name===field));},{id,field});await expect(save).toBeEnabled();await save.click();await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();expect(saves).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test);
|
||||
|
||||
for (const type of ['PromptManager', 'PromptManagerText']) {
|
||||
test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => {
|
||||
const text = `Host test ${type} ${testInfo.project.name}`;
|
||||
const saved = await request.post('/prompt_manager/save', { data: { text, category: 'Host tests', tags: ['host-test'] } });
|
||||
expect(saved.ok()).toBe(true);
|
||||
expect((await saved.json()).success).toBe(true);
|
||||
const id = await page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(type);
|
||||
app.graph.add(node);
|
||||
node.setSize([650, 800]);
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
}, type);
|
||||
const recent = page.getByRole('button', { name: '📋 Recent', exact: true });
|
||||
await expect(recent).toBeVisible();
|
||||
if (type === 'PromptManager') {
|
||||
await expect(page.locator('.pm-classic')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
}
|
||||
await recent.click();
|
||||
await page.getByText(text, { exact: true }).click();
|
||||
const state = () => page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
return Object.fromEntries(node.widgets.filter(widget => ['text', 'category', 'tags'].includes(widget.name)).map(widget => [widget.name, widget.value]));
|
||||
}, id);
|
||||
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
|
||||
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.ds.scale = .8;
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(recent).toHaveCount(1);
|
||||
await expect(recent).toBeVisible();
|
||||
expect(await state()).toMatchObject({ text });
|
||||
}
|
||||
const output = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(output.text).toBe(text);
|
||||
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
|
||||
await expect(recent).toHaveCount(1);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${type}-reloaded.png`) });
|
||||
const removedStyle = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
const style = node._styleSheet;
|
||||
app.graph.remove(node);
|
||||
return !style.isConnected;
|
||||
}, id);
|
||||
expect(removedStyle).toBe(true);
|
||||
await expect(recent).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('all node schemas and extension entrypoints load from the real host', async ({ page, request }) => {
|
||||
const definitions = await (await request.get('/object_info')).json();
|
||||
expect(definitions.PromptManager.display_name).toBe('Prompt Manager (Classic)');
|
||||
for (const name of ['PromptManager', 'PromptManagerText', 'PromptSearchList']) expect(definitions[name]?.name).toBe(name);
|
||||
const extensions = await (await request.get('/extensions')).json();
|
||||
expect(extensions.filter(path => path.startsWith('/extensions/promptmanager/'))).toEqual(['/extensions/promptmanager/prompt_manager.js']);
|
||||
const result = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('PromptSearchList');
|
||||
app.graph.add(node);
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
return { type: app.graph._nodes[0].type, extension: app.extensions.some(item => item.name === 'PromptManager.UI') };
|
||||
});
|
||||
expect(result).toEqual({ type: 'PromptSearchList', extension: true });
|
||||
});
|
||||
|
||||
for (const type of ['PromptManager', 'PromptManagerText']) {
|
||||
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
|
||||
await page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(type);
|
||||
app.graph.add(node);
|
||||
app.canvas.centerOnNode(node);
|
||||
}, type);
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||
await expect(popup.locator('body')).toContainText('Prompt');
|
||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('PromptManager');
|
||||
app.graph.add(node);
|
||||
node.setSize([650, 800]);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
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(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme', palette);
|
||||
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
await page.screenshot({ path: testInfo.outputPath(`classic-${palette}.png`) });
|
||||
}
|
||||
});
|
||||
@@ -327,3 +327,70 @@ test("missing or empty metadata yields placeholders", () => {
|
||||
assert.equal(r.checkpoint, "Unknown");
|
||||
assert.equal(extractGenerationParams(null).seed, "Unknown");
|
||||
});
|
||||
|
||||
|
||||
test("Focused API metadata composes linked positive text and preserves negative role", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
||||
2: { class_type: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
|
||||
3: { class_type: "PromptManagerFocused", inputs: { text: "blurry", prepend_text: "avoid" } },
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
||||
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
|
||||
};
|
||||
const result = extractGenerationParams({ prompt });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry");
|
||||
});
|
||||
|
||||
test("Focused workflow-only metadata recovers serialized fields and connected composition", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
|
||||
{ id: 2, type: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
|
||||
{ id: 3, type: "PromptManagerFocused", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
|
||||
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
|
||||
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
|
||||
],
|
||||
links: [
|
||||
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
|
||||
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
|
||||
],
|
||||
};
|
||||
const result = extractGenerationParams({ workflow });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
||||
assert.equal(result.checkpoint, "model.safetensors");
|
||||
});
|
||||
|
||||
test("Split API metadata composes linked positive text and preserves negative role", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
||||
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
|
||||
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
||||
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
|
||||
};
|
||||
const result = extractGenerationParams({ prompt });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry");
|
||||
});
|
||||
|
||||
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
|
||||
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
|
||||
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
|
||||
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
|
||||
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
|
||||
],
|
||||
links: [
|
||||
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
|
||||
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
|
||||
],
|
||||
};
|
||||
const result = extractGenerationParams({ workflow });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
||||
assert.equal(result.checkpoint, "model.safetensors");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const ImageHelpers = require('../../web/js/image-helpers.js');
|
||||
const {extractGenerationParams} = require('../../web/js/comfy-metadata.js');
|
||||
|
||||
function graph(positive = 0, negative = 1) {
|
||||
return {
|
||||
1:{class_type:'PromptManagerPair',inputs:{text:'cat',negative_text:'blur',prepend_text:'trigger',append_text:'detail'}},
|
||||
2:{class_type:'KSampler',inputs:{positive:['1',positive],negative:['1',negative],model:['3',0]}},
|
||||
3:{class_type:'CheckpointLoaderSimple',inputs:{ckpt_name:'m.safetensors'}},
|
||||
};
|
||||
}
|
||||
|
||||
test('paired conditioning uses actual output roles and composes only positive', () => {
|
||||
const metadata = extractGenerationParams({prompt:graph()});
|
||||
assert.equal(metadata.positivePrompt,'trigger cat detail');
|
||||
assert.equal(metadata.negativePrompt,'blur');
|
||||
const swapped = extractGenerationParams({prompt:graph(1,0)});
|
||||
assert.equal(swapped.positivePrompt,'blur');
|
||||
assert.equal(swapped.negativePrompt,'trigger cat detail');
|
||||
});
|
||||
|
||||
test('paired strings passed through encoders retain the connected output slot', () => {
|
||||
const prompt=graph();
|
||||
prompt[4]={class_type:'CLIPTextEncode',inputs:{text:['1',2]}};
|
||||
prompt[5]={class_type:'CLIPTextEncode',inputs:{text:['1',3]}};
|
||||
prompt[2].inputs.positive=['4',0];prompt[2].inputs.negative=['5',0];
|
||||
const metadata=extractGenerationParams({prompt});
|
||||
assert.equal(metadata.positivePrompt,'trigger cat detail');
|
||||
assert.equal(metadata.negativePrompt,'blur');
|
||||
});
|
||||
|
||||
test('recorded image pair overrides unrelated PNG fallback, including an empty negative', async () => {
|
||||
for (const negative of ['blur','']) {
|
||||
const context=vm.createContext({document:{addEventListener(){}},window:{},console,ImageHelpers,
|
||||
ApiClient:{fetchUrl:async()=>({ok:true,json:async()=>({success:true,prompt:{text:'raw cat',pair:{negative_text:negative},pair_context:{positive_text:'trigger cat',negative_text:negative}}})})},
|
||||
});
|
||||
const Manager=vm.runInContext(fs.readFileSync(path.join(__dirname,'../../web/js/gallery.js'),'utf8')+'\nGalleryManager;',context);
|
||||
const manager=Object.create(Manager.prototype);
|
||||
manager.extractImageMetadata=async()=>({positivePrompt:'wrong',negativePrompt:'wrong'});
|
||||
manager.displayMetadata=data=>{manager.last=data;};
|
||||
await manager.loadImageMetadata('/prompt_manager/images/serve/example.png',{});
|
||||
assert.equal(manager.last.positivePrompt,'trigger cat');
|
||||
assert.equal(manager.last.negativePrompt,negative);
|
||||
}
|
||||
});
|
||||
|
||||
test('gallery labels graph-inferred text separately from recorded execution text', () => {
|
||||
const context=vm.createContext({document:{addEventListener(){}},window:{},console,escapeHtml:value=>String(value)});
|
||||
const Manager=vm.runInContext(fs.readFileSync(path.join(__dirname,'../../web/js/gallery.js'),'utf8')+'\nGalleryManager;',context);
|
||||
const manager=Object.create(Manager.prototype);
|
||||
manager.formatPromptText=value=>String(value||'');manager.setupMetadataActions=()=>{};
|
||||
for (const [source,label] of [['graph-inferred','Recovered from workflow metadata'],['execution','Recorded during execution'],['embedded-parameters','Read from embedded parameters'],['mixed','Execution and workflow metadata']]) {
|
||||
const panel={};
|
||||
manager.displayMetadata({positivePrompt:'cat',negativePrompt:'blur',textSource:source},panel);
|
||||
assert.ok(panel.innerHTML.includes(label),source);
|
||||
}
|
||||
});
|
||||
@@ -76,3 +76,30 @@ test("progressLabel summarises a running scan for the minimized pill", () => {
|
||||
assert.equal(progressLabel(running), "Scanning 42% · 84 files · 12 prompts");
|
||||
assert.equal(progressLabel(initialState()), "");
|
||||
});
|
||||
|
||||
test('pair totals survive completion and cancellation closes the running state', () => {
|
||||
const running=reduceScanEvent(initialState(),{type:'progress',progress:30,processed:3});
|
||||
const done=reduceScanEvent(running,{type:'complete',pairs_added:2,pair_links_added:3,enriched:1,unchanged:4,ambiguous:1,failed:2});
|
||||
assert.deepEqual([done.pairs_added,done.pair_links_added,done.enriched,done.unchanged,done.ambiguous,done.failed],[2,3,1,4,1,2]);
|
||||
const cancelled=reduceScanEvent(running,{type:'cancelled',processed:3,pairs_added:1});
|
||||
assert.equal(cancelled.status,'done');
|
||||
assert.equal(cancelled.cancelled,true);
|
||||
assert.equal(cancelled.statusText,'Scan cancelled.');
|
||||
assert.equal(cancelled.progress,30);
|
||||
});
|
||||
|
||||
test('completion label reports pair enrichment, unresolved work and failures', () => {
|
||||
const {completionLabel}=require('../../web/js/scan-progress.js');
|
||||
const state=reduceScanEvent(initialState(),{type:'complete',processed:10,found:8,added:1,linked:2,pairs_added:3,pair_links_added:4,enriched:2,unchanged:1,ambiguous:1,unresolved:2,conflict:1,failed:1});
|
||||
assert.match(completionLabel(state),/3 new pairs, 4 pair links/);
|
||||
assert.match(completionLabel(state),/4 need review, 1 failed/);
|
||||
assert.match(completionLabel({...state,cancelled:true}),/^Scan cancelled!/);
|
||||
});
|
||||
|
||||
test('older servers without pair counts retain a complete readable summary', () => {
|
||||
const {completionLabel}=require('../../web/js/scan-progress.js');
|
||||
const state=reduceScanEvent(initialState(),{type:'complete',processed:2,found:1,added:1,linked:0});
|
||||
assert.match(completionLabel(initialState()),/0 new pairs, 0 pair links; 0 need review, 0 failed/);
|
||||
assert.match(completionLabel(state),/0 enriched, 0 unchanged/);
|
||||
assert.equal(fromStatus({running:true,last_event:{type:'unknown'}}).status,'running');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createScope, themeFromColor } from '../../web/js/shared/toolkit/core.js';
|
||||
|
||||
test('host background luminance determines mode without assuming palette names', () => {
|
||||
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
|
||||
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
|
||||
assert.equal(themeFromColor(''), null);
|
||||
assert.equal(themeFromColor('transparent'), null);
|
||||
});
|
||||
|
||||
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
|
||||
const scope = createScope();
|
||||
const target = new EventTarget();
|
||||
let events = 0;
|
||||
let cleanups = 0;
|
||||
scope.listen(target, 'change', () => events++);
|
||||
scope.cleanup(() => cleanups++);
|
||||
target.dispatchEvent(new Event('change'));
|
||||
assert.equal(events, 1);
|
||||
scope.dispose();
|
||||
scope.dispose();
|
||||
target.dispatchEvent(new Event('change'));
|
||||
assert.equal(events, 1);
|
||||
assert.equal(cleanups, 1);
|
||||
assert.equal(scope.signal.aborted, true);
|
||||
});
|
||||
|
||||
test('debounced work runs once with the latest value and stops after disposal', async () => {
|
||||
const scope = createScope();
|
||||
const values = [];
|
||||
const change = scope.debounce(value => values.push(value), 1);
|
||||
change('old');
|
||||
change('new');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.deepEqual(values, ['new']);
|
||||
change('cancelled');
|
||||
scope.dispose();
|
||||
change('after removal');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.deepEqual(values, ['new']);
|
||||
});
|
||||
@@ -31,8 +31,7 @@ class APITestCase(AioHTTPTestCase):
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
@@ -70,8 +70,7 @@ class AdminAPITestCase(AioHTTPTestCase):
|
||||
db_path = os.path.join(self.tmpdir, "prompts.db")
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(db_path)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(db_path))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
@@ -476,10 +475,13 @@ class TestScansRunOffTheEventLoop(AdminAPITestCase):
|
||||
).text()
|
||||
|
||||
self.assertIn('"added": 1', first)
|
||||
self.assertIn('"linked": 1', second)
|
||||
self.assertIn('"unchanged": 1', second)
|
||||
self.assertIn('"added": 0', second)
|
||||
prompts = self.api.db.get_recent_prompts(limit=10)["prompts"]
|
||||
self.assertEqual([p["text"] for p in prompts], ["a red square"])
|
||||
self.assertEqual({p["text"] for p in prompts}, {"a red square", "bad"})
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["negative_text"], "bad")
|
||||
|
||||
async def test_output_scan_without_directories_reports_error(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.outside_dir)]
|
||||
@@ -1556,7 +1558,7 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
"found",
|
||||
)
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "linked")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
|
||||
|
||||
def test_ingest_link_failures_are_tolerated(self):
|
||||
f = Path("gen.png")
|
||||
@@ -1589,7 +1591,23 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
self.assertEqual(counts, {"processed": 2, "found": 0, "added": 0, "linked": 0})
|
||||
self.assertEqual(
|
||||
counts,
|
||||
{
|
||||
"processed": 2,
|
||||
"found": 0,
|
||||
"added": 0,
|
||||
"linked": 0,
|
||||
"enriched": 0,
|
||||
"unchanged": 0,
|
||||
"ambiguous": 0,
|
||||
"unresolved": 0,
|
||||
"conflict": 0,
|
||||
"failed": 1,
|
||||
"pairs_added": 0,
|
||||
"pair_links_added": 0,
|
||||
},
|
||||
)
|
||||
|
||||
async def test_scan_internal_error_streams_error_event(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
@@ -1637,7 +1655,10 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
|
||||
prompts = self.api.db.get_recent_prompts(limit=10)["prompts"]
|
||||
self.assertEqual([p["text"] for p in prompts], ["a red square"])
|
||||
self.assertEqual({p["text"] for p in prompts}, {"a red square", "bad"})
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["negative_text"], "bad")
|
||||
self.assertEqual(self.api._scan_job.last_event["type"], "complete")
|
||||
self.assertFalse(self.api._scan_job.running)
|
||||
|
||||
@@ -1704,7 +1725,7 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
|
||||
body = await (await self.client.request("POST", "/prompt_manager/scan")).text()
|
||||
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertIn('"linked": 1', body)
|
||||
self.assertIn('"unchanged": 1', body)
|
||||
|
||||
async def test_failed_scan_is_not_left_running(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
@@ -1867,7 +1888,7 @@ class TestScanReadsVideos(AdminAPITestCase):
|
||||
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
|
||||
with self.assertLogs(level="WARNING") as cm:
|
||||
with self.assertLogs("prompt_manager", level="WARNING") as cm:
|
||||
body = await (
|
||||
await self.client.request("POST", "/prompt_manager/scan")
|
||||
).text()
|
||||
|
||||
@@ -119,8 +119,7 @@ class AutotagAPITestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
|
||||
@@ -45,8 +45,7 @@ class BackupRestoreTestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self.db_path)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self.db_path))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
@@ -322,7 +322,9 @@ class TestLongJobLock(unittest.TestCase):
|
||||
"""_acquire_long_job: one job per family at a time, released on exit."""
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
|
||||
def _run(self, coro):
|
||||
import asyncio
|
||||
@@ -373,8 +375,9 @@ class CoreRoutesTestCase(AioHTTPTestCase):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api = PromptManagerAPI(
|
||||
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
)
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
@@ -565,8 +568,9 @@ class TestInstanceHelpers(FolderPathsFixture):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api = PromptManagerAPI(
|
||||
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
)
|
||||
self.addCleanup(self.api.db.close_all) # runs before the rmtree cleanup
|
||||
orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
self.addCleanup(setattr, GalleryConfig, "MONITORING_DIRECTORIES", orig_dirs)
|
||||
@@ -668,7 +672,9 @@ class TestInstanceHelpers(FolderPathsFixture):
|
||||
class TestMetadataParsing(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmpdir, True)
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""API fixtures never open the configured database before installing test state."""
|
||||
|
||||
import unittest.mock
|
||||
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
class TestAPIDatabaseIsolation(unittest.TestCase):
|
||||
def test_injected_database_never_constructs_the_configured_database(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger"),
|
||||
):
|
||||
api = PromptManagerAPI(db=database)
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_not_called()
|
||||
database.cleanup_duplicates.assert_called_once_with()
|
||||
|
||||
def test_falsey_injected_database_is_still_used(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.__bool__.return_value = False
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger"),
|
||||
):
|
||||
api = PromptManagerAPI(db=database)
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_not_called()
|
||||
|
||||
def test_default_runtime_construction_still_uses_the_configured_factory(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.cleanup_duplicates.return_value = 2
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", return_value=database
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger") as logger,
|
||||
):
|
||||
api = PromptManagerAPI()
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_called_once_with()
|
||||
logger.return_value.info.assert_any_call(
|
||||
"Startup cleanup: removed 2 duplicate prompts"
|
||||
)
|
||||
|
||||
def test_injected_cleanup_failure_is_logged_without_falling_back_to_default(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.cleanup_duplicates.side_effect = RuntimeError("test cleanup")
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger") as logger,
|
||||
):
|
||||
api = PromptManagerAPI(db=database)
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_not_called()
|
||||
logger.return_value.error.assert_called_once_with(
|
||||
"Startup cleanup failed: test cleanup"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -64,8 +64,7 @@ class ImageAPITestCase(AioHTTPTestCase):
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
|
||||
self.api.add_routes(routes)
|
||||
|
||||
@@ -104,8 +104,9 @@ class LoggingAPITestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api = PromptManagerAPI(
|
||||
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
)
|
||||
self.api._get_logger_manager = lambda: self.manager
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
|
||||
@@ -76,8 +76,7 @@ class LoraAPITestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Pair routes preserve shared text and expose portable combinations."""
|
||||
|
||||
from tests.test_api_prompts import PromptAPITestCase
|
||||
|
||||
|
||||
class TestPairAPI(PromptAPITestCase):
|
||||
async def _create(self, **overrides):
|
||||
payload = {"positive_text": "cat", "negative_text": "blur"}
|
||||
payload.update(overrides)
|
||||
return await self.client.post("/prompt_manager/pairs", json=payload)
|
||||
|
||||
async def test_create_reuse_read_and_search(self):
|
||||
response = await self._create(tags=["studio"], title="Portrait")
|
||||
self.assertEqual(response.status, 200)
|
||||
pair = (await response.json())["pair"]
|
||||
response = await self._create(negative_text="noise")
|
||||
second = (await response.json())["pair"]
|
||||
self.assertEqual(pair["positive_prompt_id"], second["positive_prompt_id"])
|
||||
self.assertNotEqual(pair["id"], second["id"])
|
||||
response = await self.client.get("/prompt_manager/pairs?text=blur")
|
||||
data = await response.json()
|
||||
self.assertEqual(data["total"], 1)
|
||||
self.assertEqual(data["pairs"][0]["tags"], ["studio"])
|
||||
response = await self.client.get(f'/prompt_manager/pairs/{pair["id"]}')
|
||||
self.assertEqual((await response.json())["pair"]["title"], "Portrait")
|
||||
|
||||
async def test_negative_must_be_explicit_but_can_be_empty(self):
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/pairs", json={"positive_text": "cat"}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self._create(negative_text="")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNone((await response.json())["pair"]["negative_prompt_id"])
|
||||
|
||||
async def test_invalid_payloads_are_400_without_writes(self):
|
||||
for body in (
|
||||
[],
|
||||
None,
|
||||
{},
|
||||
{"positive_text": 12, "negative_text": ""},
|
||||
{"positive_text": "cat", "negative_text": None},
|
||||
{"positive_text": "cat", "negative_text": "blur", "rating": True},
|
||||
{"positive_text": "cat", "negative_text": "blur", "tags": [12]},
|
||||
):
|
||||
response = await self.client.post("/prompt_manager/pairs", json=body)
|
||||
self.assertEqual(response.status, 400, body)
|
||||
response = await self.client.get("/prompt_manager/pairs")
|
||||
self.assertEqual((await response.json())["total"], 0)
|
||||
|
||||
async def test_repeated_save_does_not_overwrite_metadata(self):
|
||||
response = await self._create(title="Original", tags=["kept"])
|
||||
pair = (await response.json())["pair"]
|
||||
response = await self._create(title="Other", tags=["ignored"])
|
||||
repeated = (await response.json())["pair"]
|
||||
self.assertEqual(repeated["id"], pair["id"])
|
||||
self.assertEqual(repeated["title"], "Original")
|
||||
self.assertEqual(repeated["tags"], ["kept"])
|
||||
|
||||
async def test_metadata_update_and_archive(self):
|
||||
pair = (await (await self._create()).json())["pair"]
|
||||
url = f'/prompt_manager/pairs/{pair["id"]}'
|
||||
response = await self.client.patch(url, json={"title": "New", "tags": ["a"]})
|
||||
self.assertEqual(response.status, 200)
|
||||
response = await self.client.patch(url, json={"positive_text": "dog"})
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.patch(url, json={"archived": True})
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
(await (await self.client.get("/prompt_manager/pairs")).json())["total"], 0
|
||||
)
|
||||
response = await self.client.get("/prompt_manager/pairs?include_archived=true")
|
||||
self.assertEqual((await response.json())["total"], 1)
|
||||
response = await self.client.delete(url)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNotNone(self.api.db.get_prompt_by_id(pair["positive_prompt_id"]))
|
||||
|
||||
async def test_export_is_versioned_and_contains_text_not_only_local_ids(self):
|
||||
await self._create(tags=["a"])
|
||||
response = await self.client.get("/prompt_manager/pairs/export")
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["schema_version"], 1)
|
||||
self.assertEqual(data["pairs"][0]["positive_text"], "cat")
|
||||
self.assertEqual(data["pairs"][0]["negative_text"], "blur")
|
||||
self.assertNotIn("positive_prompt_id", data["pairs"][0])
|
||||
|
||||
async def test_missing_invalid_ids_and_pagination(self):
|
||||
for verb in ("get", "delete"):
|
||||
for suffix, status in (("abc", 400), ("9999", 404)):
|
||||
response = await getattr(self.client, verb)(
|
||||
f"/prompt_manager/pairs/{suffix}"
|
||||
)
|
||||
self.assertEqual(response.status, status)
|
||||
for query in ("limit=abc", "offset=abc", "include_archived=maybe"):
|
||||
response = await self.client.get("/prompt_manager/pairs?" + query)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.get("/prompt_manager/pairs?limit=99999&offset=-1")
|
||||
data = await response.json()
|
||||
self.assertEqual(data["limit"], 500)
|
||||
self.assertEqual(data["offset"], 0)
|
||||
|
||||
async def test_old_edit_and_delete_routes_report_pair_dependency(self):
|
||||
pair = (await (await self._create()).json())["pair"]
|
||||
prompt_id = pair["positive_prompt_id"]
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{prompt_id}", json={"text": "dog"}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
response = await self.client.delete(f"/prompt_manager/delete/{prompt_id}")
|
||||
self.assertEqual(response.status, 409)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/delete", json={"prompt_ids": [prompt_id]}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
|
||||
async def test_recent_pairs_sort_by_use_and_reject_unknown_sort(self):
|
||||
first = (await (await self._create()).json())["pair"]
|
||||
await self._create(negative_text="noise")
|
||||
self.api.db.record_prompt_pair_use(first["id"])
|
||||
response = await self.client.get("/prompt_manager/pairs?sort=last_used_desc")
|
||||
self.assertEqual((await response.json())["pairs"][0]["id"], first["id"])
|
||||
response = await self.client.get("/prompt_manager/pairs?sort=invalid")
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_null_metadata_patch_is_noop_for_existing_pair(self):
|
||||
pair = (await (await self._create(rating=4)).json())["pair"]
|
||||
response = await self.client.patch(
|
||||
f'/prompt_manager/pairs/{pair["id"]}', json={"rating": None}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual((await response.json())["pair"]["rating"], 4)
|
||||
response = await self.client.patch(
|
||||
"/prompt_manager/pairs/99999", json={"rating": None}
|
||||
)
|
||||
self.assertEqual(response.status, 404)
|
||||
|
||||
async def test_database_failures_are_generic_and_bad_json_is_rejected(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(
|
||||
self.api.db,
|
||||
"save_prompt_pair",
|
||||
side_effect=RuntimeError("/private/db/path"),
|
||||
):
|
||||
response = await self._create()
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertNotIn("/private", await response.text())
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/pairs",
|
||||
data="{",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
@@ -17,6 +17,11 @@ sys.modules.setdefault("server", MagicMock())
|
||||
from aiohttp import web # noqa: E402
|
||||
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
import py.api.prompts as prompts_module # noqa: E402
|
||||
@@ -38,8 +43,7 @@ class PromptAPITestCase(AioHTTPTestCase):
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
@@ -48,6 +52,7 @@ class PromptAPITestCase(AioHTTPTestCase):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
# Windows refuses to unlink a database that still has open handles.
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name)
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
self._temp_db.name + "-wal",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Focused shares encoding and tracking while exposing composition sockets."""
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from prompt_manager import PromptManager, PromptManagerFocused
|
||||
from utils.prompt_graph import positive_prompt_nodes, run_prompt_nodes
|
||||
|
||||
|
||||
class TestFocusedNode(unittest.TestCase):
|
||||
def test_focused_keeps_the_execution_contract_and_visible_string_inputs(self):
|
||||
self.assertTrue(issubclass(PromptManagerFocused, PromptManager))
|
||||
self.assertEqual(PromptManagerFocused.RETURN_TYPES, PromptManager.RETURN_TYPES)
|
||||
self.assertEqual(PromptManagerFocused.FUNCTION, PromptManager.FUNCTION)
|
||||
focused = PromptManagerFocused.INPUT_TYPES()
|
||||
for name in ("prepend_text", "append_text"):
|
||||
self.assertTrue(focused["optional"][name][1]["forceInput"])
|
||||
self.assertNotIn(
|
||||
"forceInput", PromptManager.INPUT_TYPES()["optional"][name][1]
|
||||
)
|
||||
self.assertEqual(focused["hidden"]["unique_id"], "UNIQUE_ID")
|
||||
|
||||
def test_focused_encodes_composition_and_tracks_main_text_and_graph(self):
|
||||
node = PromptManagerFocused.__new__(PromptManagerFocused)
|
||||
node.logger = logging.getLogger("test.focused")
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = mock.Mock(return_value=42)
|
||||
clip = mock.Mock()
|
||||
graph = {"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}}}
|
||||
conditioning, text = node.encode_prompt(
|
||||
clip,
|
||||
"cat",
|
||||
prepend_text="trigger",
|
||||
append_text="detailed",
|
||||
prompt=graph,
|
||||
unique_id="1",
|
||||
)
|
||||
self.assertEqual(text, "trigger cat detailed")
|
||||
clip.tokenize.assert_called_once_with(text)
|
||||
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
|
||||
tracked = node._track_prompt_execution.call_args.kwargs
|
||||
self.assertEqual(tracked["text"], "cat")
|
||||
self.assertEqual(tracked["prompt_graph"], graph)
|
||||
|
||||
def test_focused_is_traced_as_positive_and_not_as_the_negative_run(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
|
||||
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["2", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
@@ -299,7 +299,9 @@ class TestGetAllOutputDirsFiltersRoots(GalleryRootTestCase):
|
||||
super().setUp()
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
self.api._cached_output_dir = None
|
||||
self._orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
self.addCleanup(self._restore_dirs)
|
||||
|
||||
@@ -135,9 +135,26 @@ class TestPackageWiring(PackageTestCase):
|
||||
def test_nodes_are_registered(self):
|
||||
self.assertEqual(
|
||||
sorted(self.package.NODE_CLASS_MAPPINGS),
|
||||
["PromptManager", "PromptManagerText", "PromptSearchList"],
|
||||
[
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerPair",
|
||||
"PromptManagerSplit",
|
||||
"PromptManagerText",
|
||||
"PromptSearchList",
|
||||
],
|
||||
)
|
||||
|
||||
def test_split_display_name_and_execution_contract_are_registered(self):
|
||||
node = self.package.NODE_CLASS_MAPPINGS["PromptManagerSplit"]
|
||||
self.assertEqual(
|
||||
self.package.NODE_DISPLAY_NAME_MAPPINGS["PromptManagerSplit"],
|
||||
"Prompt Manager (Split)",
|
||||
)
|
||||
focused = self.package.NODE_CLASS_MAPPINGS["PromptManagerFocused"]
|
||||
self.assertTrue(issubclass(node, focused))
|
||||
self.assertEqual(node.INPUT_TYPES(), focused.INPUT_TYPES())
|
||||
|
||||
def test_db_factory_returns_the_shared_database(self):
|
||||
self.assertIsNotNone(self.package._global_db)
|
||||
self.assertIs(self.package._db_factory(), self.package._global_db)
|
||||
|
||||
@@ -9,7 +9,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
@@ -26,7 +26,16 @@ class TestFindComfyuiOutputDir(unittest.TestCase):
|
||||
"""Test _find_comfyui_output_dir() respects user-configured directories."""
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
roots = patch.object(
|
||||
GalleryConfig,
|
||||
"allowed_gallery_parents",
|
||||
return_value=[os.path.normcase(os.path.realpath(tempfile.gettempdir()))],
|
||||
)
|
||||
roots.start()
|
||||
self.addCleanup(roots.stop)
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
self.api._cached_output_dir = None
|
||||
self._orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
|
||||
@@ -112,5 +121,22 @@ class TestFindComfyuiOutputDir(unittest.TestCase):
|
||||
self.assertEqual(result, str(Path(tmpdir).resolve()))
|
||||
|
||||
|
||||
class TestOutputDirectoryFixturePortability(unittest.TestCase):
|
||||
def test_aliased_temporary_parent_uses_the_real_gallery_allowlist(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
(parent / "alias").mkdir()
|
||||
(parent / "actual").mkdir()
|
||||
aliased = str(parent / "alias" / ".." / "actual")
|
||||
with patch("tempfile.gettempdir", return_value=aliased):
|
||||
case = TestFindComfyuiOutputDir(
|
||||
"test_configured_directory_takes_priority"
|
||||
)
|
||||
result = unittest.TestResult()
|
||||
case.run(result)
|
||||
self.assertEqual(result.errors, [])
|
||||
self.assertEqual(result.failures, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Dependency checks exclude competing pair writers until mutation finishes."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest.mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class TestPairDependencyConcurrency(unittest.TestCase):
|
||||
def test_legacy_mutators_lock_out_pair_creation_before_dependency_checks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "concurrent.db")
|
||||
db = PromptDatabase(path)
|
||||
try:
|
||||
original = db._ensure_prompts_unpaired
|
||||
checks = []
|
||||
|
||||
def check_with_competing_writer(conn, ids):
|
||||
original(conn, ids)
|
||||
competitor = sqlite3.connect(path, timeout=0)
|
||||
try:
|
||||
with self.assertRaisesRegex(sqlite3.OperationalError, "locked"):
|
||||
competitor.execute("BEGIN IMMEDIATE")
|
||||
finally:
|
||||
competitor.close()
|
||||
checks.append(list(ids))
|
||||
|
||||
actions = (
|
||||
lambda pid: db.update_prompt_text(pid, "changed"),
|
||||
db.delete_prompt,
|
||||
lambda pid: db.bulk_delete_prompts([pid]),
|
||||
lambda _pid: db.delete_prompts_by_category("temporary"),
|
||||
)
|
||||
with unittest.mock.patch.object(
|
||||
db, "_ensure_prompts_unpaired", check_with_competing_writer
|
||||
):
|
||||
for index, action in enumerate(actions):
|
||||
with self.subTest(action=index):
|
||||
text = f"unpaired {index}"
|
||||
pid = db.save_prompt(
|
||||
text,
|
||||
category="temporary",
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
)
|
||||
self.assertTrue(action(pid))
|
||||
self.assertEqual(len(checks), len(actions))
|
||||
finally:
|
||||
db.close_all()
|
||||
assert_closed(self, directory)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Embedded actual encoding evidence is used only for its traced paired node."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
from utils.metadata_extractor import ComfyUIMetadataExtractor
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
KEY = "comfyassets_prompt_pairs"
|
||||
|
||||
|
||||
def graph(positive="cat", negative="blur"):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": positive, "negative_text": negative},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["1", 1]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPairExecution(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(str(Path(self.tmp.name) / "test.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.node = PromptManagerPair.__new__(PromptManagerPair)
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.execution")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = lambda text: text + " LORA"
|
||||
self.extra = {"workflow": {"unchanged": True}, "sibling": {"keep": True}}
|
||||
|
||||
def execute(self, text="cat", negative="blur", **kwargs):
|
||||
return self.node.encode_prompt(
|
||||
Mock(),
|
||||
text,
|
||||
negative_text=negative,
|
||||
unique_id="1",
|
||||
extra_pnginfo=self.extra,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def resolve(self, source=None, extra=None):
|
||||
return resolve_prompt_pair(
|
||||
{"prompt": source or graph(), **(self.extra if extra is None else extra)}
|
||||
)
|
||||
|
||||
def test_schema_requests_extra_pnginfo_and_actual_positive_lora_is_preserved(self):
|
||||
self.assertEqual(
|
||||
PromptManagerPair.INPUT_TYPES()["hidden"]["extra_pnginfo"], "EXTRA_PNGINFO"
|
||||
)
|
||||
self.execute(prepend_text="trigger")
|
||||
source = graph()
|
||||
source["1"]["inputs"]["prepend_text"] = "trigger"
|
||||
result = self.resolve(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], "trigger cat LORA")
|
||||
self.assertEqual(result["positive"]["storage_text"], "cat")
|
||||
self.assertEqual(result["positive"]["provenance"]["text_source"], "execution")
|
||||
self.assertEqual(result["negative"]["text"], "blur")
|
||||
self.assertEqual(self.extra["workflow"], {"unchanged": True})
|
||||
self.assertEqual(self.extra["sibling"], {"keep": True})
|
||||
|
||||
def test_one_unknown_linked_execution_is_recoverable_without_fifo_guess(self):
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="actual batch cat")
|
||||
result = self.resolve(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["storage_text"], "actual batch cat")
|
||||
self.assertEqual(result["positive"]["text"], "actual batch cat LORA")
|
||||
|
||||
def test_distinct_batch_records_are_unresolved_instead_of_using_last_execution(
|
||||
self,
|
||||
):
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="first")
|
||||
self.execute(text="second")
|
||||
result = self.resolve(source)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertIn("multiple", result["reason"].lower())
|
||||
|
||||
def test_empty_negative_runtime_record_remains_explicit(self):
|
||||
self.execute(negative="")
|
||||
result = self.resolve(graph(negative=""))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["negative"]["text"], "")
|
||||
|
||||
def test_cached_graph_without_execution_evidence_is_explicitly_inferred(self):
|
||||
result = self.resolve(extra={})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_stale_or_malformed_evidence_is_not_accepted(self):
|
||||
self.execute(text="stale")
|
||||
self.assertEqual(self.resolve()["state"], "unresolved")
|
||||
for value in (
|
||||
"not json",
|
||||
[],
|
||||
{"version": 900, "nodes": {}},
|
||||
{"version": 1, "nodes": {"1": {"records": [{"bad": True}]}}},
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(
|
||||
self.resolve(extra={KEY: value})["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_unrelated_node_evidence_does_not_override_plain_encoders(self):
|
||||
source = graph()
|
||||
source["1"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "plain positive"},
|
||||
}
|
||||
source["2"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "plain negative"},
|
||||
}
|
||||
source["5"]["inputs"]["negative"] = ["2", 0]
|
||||
result = self.resolve(source, extra={KEY: "broken"})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], "plain positive")
|
||||
|
||||
def test_png_extractor_preserves_namespace_for_live_and_raw_scan_readers(self):
|
||||
self.execute()
|
||||
path = Path(self.tmp.name) / "image.png"
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph()))
|
||||
info.add_text(KEY, json.dumps(self.extra[KEY]))
|
||||
Image.new("RGB", (2, 2)).save(path, pnginfo=info)
|
||||
parsed = ComfyUIMetadataExtractor().extract_metadata(str(path))
|
||||
self.assertEqual(resolve_prompt_pair(parsed)["positive"]["text"], "cat LORA")
|
||||
with Image.open(path) as image:
|
||||
raw = dict(image.text)
|
||||
raw["prompt"] = json.loads(raw["prompt"])
|
||||
self.assertEqual(resolve_prompt_pair(raw)["positive"]["text"], "cat LORA")
|
||||
|
||||
def test_recording_limits_fail_closed_and_repeat_identical_values_are_safe(self):
|
||||
from utils.pair_execution import MAX_EXECUTION_RECORDS
|
||||
|
||||
self.execute()
|
||||
self.execute()
|
||||
self.assertEqual(self.resolve()["state"], "resolved")
|
||||
for index in range(MAX_EXECUTION_RECORDS + 1):
|
||||
self.execute(text=str(index))
|
||||
self.assertEqual(self.resolve()["state"], "unresolved")
|
||||
|
||||
def test_live_metadata_lookup_uses_unique_execution_for_unknown_source(self):
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="actual")
|
||||
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
handler.db_manager = self.db
|
||||
handler.logger = logging.getLogger("test.execution")
|
||||
state, context = handler._find_prompt_from_metadata(
|
||||
{"prompt": source, **self.extra}
|
||||
)
|
||||
self.assertEqual(state, "linked")
|
||||
expected = self.db.list_prompt_pairs()["pairs"][0]["positive_prompt_id"]
|
||||
self.assertEqual(context["id"], expected)
|
||||
|
||||
def test_submission_clears_old_execution_evidence_without_changing_workflow(self):
|
||||
from utils.usage_tracking import handle_queued_prompt, QueuedRuns
|
||||
|
||||
self.execute()
|
||||
request = {"prompt": graph(), "extra_data": {"extra_pnginfo": self.extra}}
|
||||
handle_queued_prompt(request, QueuedRuns(), pair_queued=QueuedRuns())
|
||||
self.assertNotIn(KEY, self.extra)
|
||||
self.assertEqual(self.extra["workflow"], {"unchanged": True})
|
||||
self.assertEqual(
|
||||
self.resolve()["positive"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_known_invalid_output_slot_or_cycles_cannot_be_overridden(self):
|
||||
self.execute()
|
||||
source = graph(positive=["2", 4])
|
||||
source["2"] = {"class_type": "PrimitiveString", "inputs": {"value": "cat"}}
|
||||
self.assertEqual(self.resolve(source)["state"], "unresolved")
|
||||
source["2"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": ["2", 0], "string_b": "cat"},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["2", 0]
|
||||
self.assertEqual(self.resolve(source)["state"], "unresolved")
|
||||
|
||||
def test_live_image_association_keeps_actual_encoding_and_repeated_scan_is_stable(
|
||||
self,
|
||||
):
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
|
||||
self.execute()
|
||||
metadata = {"prompt": graph(), **self.extra}
|
||||
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
handler.db_manager = self.db
|
||||
handler.logger = logging.getLogger("test.execution")
|
||||
_, context = handler._find_prompt_from_metadata(metadata)
|
||||
path = str(Path(self.tmp.name) / "actual.png")
|
||||
handler.link_image_to_prompt(path, context, metadata)
|
||||
pair_context = self.db.get_image_prompt_info(path)["pair_context"]
|
||||
self.assertEqual(pair_context["positive_text"], "cat LORA")
|
||||
repeated = ingest_resolved_pair(
|
||||
self.db, path, metadata, resolve_prompt_pair(metadata)
|
||||
)
|
||||
self.assertEqual(repeated["status"], "unchanged")
|
||||
|
||||
def test_unknown_batch_with_multiple_executions_is_not_exactly_linked(self):
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="first")
|
||||
self.execute(text="second")
|
||||
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
handler.db_manager = self.db
|
||||
handler.logger = logging.getLogger("test.execution")
|
||||
state, context = handler._find_prompt_from_metadata(
|
||||
{"prompt": source, **self.extra}
|
||||
)
|
||||
self.assertEqual(state, "unknown")
|
||||
self.assertIsNone(context)
|
||||
|
||||
def test_execution_evidence_bounds_and_malformed_structures_fail_closed(self):
|
||||
from utils import pair_execution as evidence
|
||||
from unittest.mock import patch
|
||||
|
||||
kwargs = dict(
|
||||
text="cat",
|
||||
negative_text="blur",
|
||||
positive_text="cat LORA",
|
||||
negative_effective_text="blur",
|
||||
)
|
||||
evidence.record_pair_execution(None, "1", **kwargs)
|
||||
envelope = {}
|
||||
evidence.record_pair_execution(envelope, None, **kwargs)
|
||||
self.assertEqual(envelope, {})
|
||||
evidence.record_pair_execution(envelope, "1", **kwargs)
|
||||
self.assertIsNone(evidence.read_pair_execution(envelope, "untraced"))
|
||||
malformed = (
|
||||
{"version": 1, "nodes": []},
|
||||
{"version": 1, "truncated": True, "nodes": {}},
|
||||
{"version": 1, "nodes": {"1": []}},
|
||||
{"version": 1, "nodes": {"1": {"records": []}}},
|
||||
{"version": 1, "nodes": {"1": {"records": "bad"}}},
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": {
|
||||
"1": {
|
||||
"records": [
|
||||
{
|
||||
**kwargs,
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"negative_effective_text": "changed",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
for value in malformed:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution({KEY: value}, "1")
|
||||
oversized = {}
|
||||
evidence.record_pair_execution(
|
||||
oversized,
|
||||
"1",
|
||||
**{**kwargs, "text": "x" * (evidence.MAX_EXECUTION_TEXT + 1)},
|
||||
)
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution(oversized, "1")
|
||||
with patch.object(evidence, "MAX_EXECUTION_JSON", 10):
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution({KEY: json.dumps(envelope[KEY])}, "1")
|
||||
limited = {}
|
||||
evidence.record_pair_execution(limited, "1", **kwargs)
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution(limited, "1")
|
||||
with patch.object(evidence, "MAX_EXECUTION_NODES", 1):
|
||||
evidence.record_pair_execution(envelope, "2", **kwargs)
|
||||
evidence.record_pair_execution(envelope, "3", **kwargs)
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution(envelope, "1")
|
||||
|
||||
def test_corrupted_existing_namespace_cannot_break_valid_encoding(self):
|
||||
from utils.pair_execution import (
|
||||
record_pair_execution,
|
||||
read_pair_execution,
|
||||
ExecutionEvidenceError,
|
||||
)
|
||||
|
||||
kwargs = dict(
|
||||
text="cat",
|
||||
negative_text="blur",
|
||||
positive_text="cat LORA",
|
||||
negative_effective_text="blur",
|
||||
)
|
||||
for nodes in ([], {"1": {"records": "bad"}}, {"other": {"bad": object()}}):
|
||||
extra = {KEY: {"version": 1, "nodes": nodes}}
|
||||
record_pair_execution(extra, "1", **kwargs)
|
||||
with self.assertRaises(ExecutionEvidenceError):
|
||||
read_pair_execution(extra, "1")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Live metadata annotation uses the same conservative ingestion as scan."""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
|
||||
def metadata(positive="cat", negative="blur"):
|
||||
return {
|
||||
"prompt": {
|
||||
"1": {"class_type": "PromptManager", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestPairImageLinking(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "db.sqlite"))
|
||||
self.path = str(Path(self.temp.name) / "image.png")
|
||||
self.pid = self.db.save_prompt("cat", prompt_hash=generate_prompt_hash("cat"))
|
||||
self.handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
self.handler.db_manager = self.db
|
||||
self.handler.logger = logging.getLogger("test.pairlink")
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_exact_metadata_creates_pair_link_without_counting_a_run(self):
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, metadata())
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, metadata())
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def test_stale_context_or_ambiguous_metadata_does_not_create_pair(self):
|
||||
meta = metadata(positive="dog")
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
meta = metadata()
|
||||
meta["prompt"]["4"] = meta["prompt"]["3"]
|
||||
self.handler.link_image_to_prompt(self.path + "2", {"id": self.pid}, meta)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Atomic, repeatable metadata enrichment without changing usage counts."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def resolved(positive="cat", negative="blur"):
|
||||
return {
|
||||
"state": "resolved",
|
||||
"positive": {"text": positive, "provenance": {"source": "api"}},
|
||||
"negative": {"text": negative, "provenance": {"source": "api"}},
|
||||
}
|
||||
|
||||
|
||||
class TestPairIngestion(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "test.db"))
|
||||
self.path = str(Path(self.temp.name) / "image.png")
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_first_scan_repeat_and_negative_variants_reuse_components(self):
|
||||
first = ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
self.assertEqual(first["status"], "added")
|
||||
self.assertEqual(first["pairs_added"], 1)
|
||||
second = ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
self.assertEqual(second["status"], "unchanged")
|
||||
self.assertEqual(second["pairs_added"], 0)
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_hash(generate_prompt_hash("cat"))["run_count"], 0
|
||||
)
|
||||
ingest_resolved_pair(self.db, self.path + "2", {}, resolved(negative="noise"))
|
||||
self.assertEqual(
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT count(*) FROM prompts")
|
||||
.fetchone()[0],
|
||||
3,
|
||||
)
|
||||
|
||||
def test_existing_image_enriched_in_place_and_metadata_preserved(self):
|
||||
pid = self.db.save_prompt(
|
||||
"cat", category="Personal", prompt_hash=generate_prompt_hash("cat")
|
||||
)
|
||||
image_id = self.db.link_image_to_prompt(pid, self.path)
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, {"prompt": {"a": 1}}, resolved()
|
||||
)
|
||||
self.assertEqual(result["status"], "enriched")
|
||||
self.assertEqual(result["image_id"], image_id)
|
||||
self.assertEqual(self.db.get_prompt_by_id(pid)["category"], "Personal")
|
||||
row = self.db.get_image_by_id(image_id)
|
||||
self.assertIn("a", row["prompt_metadata"])
|
||||
|
||||
def test_conflicting_image_is_not_reassigned_or_duplicated(self):
|
||||
pid = self.db.save_prompt("dog", prompt_hash=generate_prompt_hash("dog"))
|
||||
self.db.link_image_to_prompt(pid, self.path)
|
||||
result = ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
self.assertEqual(result["status"], "conflict")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT count(*) FROM generated_images")
|
||||
.fetchone()[0],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_unknown_and_missing_negative_never_become_empty_pairs(self):
|
||||
for state in ("absent", "ambiguous", "unresolved"):
|
||||
value = resolved()
|
||||
value["state"] = state
|
||||
result = ingest_resolved_pair(self.db, self.path, {}, value)
|
||||
self.assertEqual(result["status"], state)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
result = ingest_resolved_pair(self.db, self.path, {}, resolved(negative=""))
|
||||
self.assertEqual(result["status"], "added")
|
||||
|
||||
def test_failure_after_inserts_rolls_back_all_rows(self):
|
||||
with patch.object(
|
||||
self.db, "_link_image_to_pair", side_effect=RuntimeError("injected")
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
conn = self.db.model.get_connection()
|
||||
for table in (
|
||||
"prompts",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"image_prompt_pairs",
|
||||
):
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT count(*) FROM {table}").fetchone()[0], 0
|
||||
)
|
||||
|
||||
def test_distinct_paths_are_not_collapsed_and_effective_text_stays_in_provenance(
|
||||
self,
|
||||
):
|
||||
value = resolved("prefix cat suffix")
|
||||
value["positive"]["storage_text"] = "cat"
|
||||
a = ingest_resolved_pair(self.db, self.path, {}, value)
|
||||
b = ingest_resolved_pair(
|
||||
self.db, self.path.replace("image.png", "other/image.png"), {}, value
|
||||
)
|
||||
self.assertEqual(a["pair_id"], b["pair_id"])
|
||||
pair = self.db.get_prompt_pair(a["pair_id"])
|
||||
self.assertEqual(pair["positive_text"], "cat")
|
||||
self.assertEqual(pair["image_count"], 2)
|
||||
association = self.db.get_image_prompt_pairs(a["image_id"])[0]
|
||||
self.assertEqual(
|
||||
association["provenance"]["positive"]["text"], "prefix cat suffix"
|
||||
)
|
||||
|
||||
def test_image_lookup_exposes_its_recorded_pair_not_latest_negative(self):
|
||||
value = resolved("trigger cat", "blur")
|
||||
value["positive"]["storage_text"] = "cat"
|
||||
first = ingest_resolved_pair(self.db, self.path, {}, value)
|
||||
ingest_resolved_pair(self.db, self.path + "2", {}, resolved("cat", "noise"))
|
||||
info = self.db.get_image_prompt_info(self.path)
|
||||
self.assertEqual(info["pair"]["id"], first["pair_id"])
|
||||
self.assertEqual(info["pair"]["negative_text"], "blur")
|
||||
self.assertEqual(info["pair_context"]["positive_text"], "trigger cat")
|
||||
self.assertEqual(info["pair_context"]["negative_text"], "blur")
|
||||
|
||||
def test_overwritten_file_with_different_negative_reports_conflict(self):
|
||||
ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, {}, resolved(negative="noise")
|
||||
)
|
||||
self.assertEqual(result["status"], "conflict")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 1)
|
||||
|
||||
def test_execution_evidence_wins_over_a_prior_inferred_context(self):
|
||||
inferred = resolved()
|
||||
for side in ("positive", "negative"):
|
||||
inferred[side]["provenance"]["text_source"] = "graph-inferred"
|
||||
ingest_resolved_pair(self.db, self.path, {}, inferred)
|
||||
execution = resolved("cat, lora_trigger", "blur")
|
||||
execution["positive"]["storage_text"] = "cat"
|
||||
for side in ("positive", "negative"):
|
||||
execution[side]["provenance"]["text_source"] = "execution"
|
||||
ingest_resolved_pair(self.db, self.path, {}, execution)
|
||||
info = self.db.get_image_prompt_info(self.path)
|
||||
self.assertEqual(info["pair_context"]["positive_text"], "cat, lora_trigger")
|
||||
self.assertEqual(info["pair_context"]["text_source"], "execution")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Actual sampler wires determine which component counts as its positive text."""
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.usage_tracking import queued_prompt_runs, queued_pair_runs
|
||||
|
||||
|
||||
def paired_graph(positive_slot=0, negative_slot=1):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "cat", "negative_text": "blur"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["3", 0],
|
||||
"positive": ["1", positive_slot],
|
||||
"negative": ["1", negative_slot],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPairOutputRoles(unittest.TestCase):
|
||||
def test_swapped_conditioning_counts_actual_positive_component_only(self):
|
||||
graph = paired_graph(1, 0)
|
||||
self.assertEqual(
|
||||
queued_prompt_runs(graph), [(generate_prompt_hash("blur"), "blur")]
|
||||
)
|
||||
self.assertEqual(queued_pair_runs(graph), [])
|
||||
|
||||
def test_negative_string_encoded_as_positive_counts_actual_string_component(self):
|
||||
graph = paired_graph()
|
||||
graph["4"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 3]}}
|
||||
graph["2"]["inputs"]["positive"] = ["4", 0]
|
||||
self.assertEqual(
|
||||
queued_prompt_runs(graph), [(generate_prompt_hash("blur"), "blur")]
|
||||
)
|
||||
self.assertEqual(queued_pair_runs(graph), [])
|
||||
|
||||
def test_two_sampler_positive_roles_count_each_component_once(self):
|
||||
graph = paired_graph()
|
||||
graph["5"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"model": ["3", 0], "positive": ["1", 1], "negative": ["1", 0]},
|
||||
}
|
||||
self.assertEqual(
|
||||
{text for _, text in queued_prompt_runs(graph)}, {"cat", "blur"}
|
||||
)
|
||||
|
||||
|
||||
class TestPairRoleExecution(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from tests.test_prompt_pair_node import TestPromptPairNode
|
||||
|
||||
TestPromptPairNode.setUp(self)
|
||||
|
||||
def test_new_swapped_component_consumes_its_queued_pending_use(self):
|
||||
self.node.pending_first_use.add(generate_prompt_hash("blur"))
|
||||
self.node.encode_prompt(
|
||||
self.clip, "cat", "blur", prompt=paired_graph(1, 0), unique_id="1"
|
||||
)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 1
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 0
|
||||
)
|
||||
self.assertEqual(pair["run_count"], 0)
|
||||
|
||||
def test_unknown_negative_link_used_as_positive_counts_at_execution(self):
|
||||
graph = paired_graph(1, 0)
|
||||
graph["1"]["inputs"]["negative_text"] = ["8", 0]
|
||||
graph["8"] = {"class_type": "UnknownBatch", "inputs": {}}
|
||||
self.node.encode_prompt(self.clip, "cat", "blur", prompt=graph, unique_id="1")
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 1
|
||||
)
|
||||
self.assertEqual(pair["run_count"], 0)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Restores preserve pair ownership and bounded, readable JSON provenance."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest.mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
|
||||
class TestPairRestoreSemantics(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(assert_closed, self, directory.name)
|
||||
self.path = os.path.join(directory.name, "restore.db")
|
||||
self.db = PromptDatabase(self.path)
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.pair_id = self.db.save_prompt_pair("portrait", "blurry")
|
||||
pair = self.db.get_prompt_pair(self.pair_id)
|
||||
self.image_id = self.db.link_image_to_prompt(
|
||||
pair["positive_prompt_id"], os.path.join(directory.name, "image.png")
|
||||
)
|
||||
|
||||
def raw_link(self, provenance, pair_id=None):
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM image_prompt_pairs")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(image_id,pair_id,provenance) "
|
||||
"VALUES(?,?,?)",
|
||||
(self.image_id, pair_id or self.pair_id, provenance),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def test_restore_rejects_pair_with_different_image_positive_owner(self):
|
||||
other = self.db.save_prompt_pair("landscape", "blurry")
|
||||
self.raw_link("{}", other)
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("positive", reason.lower())
|
||||
|
||||
def test_restore_rejects_unreadable_nonobject_and_nonfinite_provenance(self):
|
||||
for value in (
|
||||
"not JSON",
|
||||
"[]",
|
||||
"null",
|
||||
"false",
|
||||
"42",
|
||||
'"text"',
|
||||
'{"value":NaN}',
|
||||
'{"value":Infinity}',
|
||||
'{"value":-Infinity}',
|
||||
'{"value":1e999}',
|
||||
b'{"blob":true}',
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.raw_link(value)
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
def test_restore_retains_valid_nested_object_provenance(self):
|
||||
provenance = {"format": "future", "nested": [{"empty": "", "value": 2.5}]}
|
||||
self.raw_link(json.dumps(provenance))
|
||||
self.assertEqual(PromptModel.verify_database_file(self.path), (True, "ok"))
|
||||
self.assertEqual(
|
||||
self.db.get_image_prompt_pairs(self.image_id)[0]["provenance"], provenance
|
||||
)
|
||||
|
||||
def test_restore_rejects_excessive_json_nesting_without_raising(self):
|
||||
self.raw_link('{"nested":' * 1500 + "0" + "}" * 1500)
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
def test_restore_and_runtime_share_the_provenance_size_limit(self):
|
||||
provenance = {"large": "x" * (4_000_000 + 1)}
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(self.pair_id, self.image_id, provenance)
|
||||
self.raw_link(json.dumps(provenance))
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
def test_runtime_normalizes_deep_json_encoding_error(self):
|
||||
provenance = {}
|
||||
child = provenance
|
||||
for _ in range(1500):
|
||||
child["nested"] = {}
|
||||
child = child["nested"]
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(self.pair_id, self.image_id, provenance)
|
||||
self.assertEqual(self.db.get_image_prompt_pairs(self.image_id), [])
|
||||
|
||||
def test_runtime_converts_encoder_recursion_failure_to_validation_error(self):
|
||||
with unittest.mock.patch(
|
||||
"database.pairs.json.dumps", side_effect=RecursionError("depth")
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "JSON"):
|
||||
self.db.link_image_to_pair(self.pair_id, self.image_id, {})
|
||||
|
||||
def test_restore_converts_parser_recursion_failure_to_rejection(self):
|
||||
self.raw_link("{}")
|
||||
with unittest.mock.patch(
|
||||
"database.models.json.loads", side_effect=RecursionError("depth")
|
||||
):
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Metadata scanning enriches known pairs and conservatively leaves conflicts."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
|
||||
|
||||
def graph(positive="cat", negative="blur"):
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPairScan(AdminAPITestCase):
|
||||
def test_pair_scan_repeated_is_unchanged_and_empty_negative_is_supported(self):
|
||||
path = self.output_dir / "pair.png"
|
||||
meta = {"prompt": json.dumps(graph(negative=""))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["negative_text"], "")
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def test_ambiguous_sampler_scan_does_not_save_a_guess(self):
|
||||
g = graph()
|
||||
g["4"] = {**g["3"]}
|
||||
meta = {"prompt": json.dumps(g)}
|
||||
self.assertEqual(
|
||||
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
|
||||
"ambiguous",
|
||||
)
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_batch_exposes_pair_counts_and_actual_unchanged_status(self):
|
||||
meta = {"parameters": "cat\nNegative prompt: blur\nSteps: 20, Sampler: Euler"}
|
||||
batch = [(Path(self.output_dir / "a.png"), meta)]
|
||||
first = self.api._ingest_scan_batch_sync(batch)
|
||||
self.assertEqual(first["pairs_added"], 1)
|
||||
self.assertEqual(first["pair_links_added"], 1)
|
||||
second = self.api._ingest_scan_batch_sync(batch)
|
||||
self.assertEqual(second["unchanged"], 1)
|
||||
self.assertEqual(second["linked"], 0)
|
||||
self.assertEqual(second["pairs_added"], 0)
|
||||
|
||||
def test_legacy_single_prompt_second_pass_not_reported_as_new_link(self):
|
||||
meta = {"prompt": json.dumps({"1": graph()["1"]})}
|
||||
path = self.output_dir / "old.png"
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
|
||||
|
||||
async def test_cancel_finishes_at_safe_boundary_and_restart_is_idempotent(self):
|
||||
from py.api.scan_job import ScanJob
|
||||
|
||||
job = ScanJob()
|
||||
self.api._scan_job = job
|
||||
response = await self.client.post("/prompt_manager/scan/cancel")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertTrue((await response.json())["cancellation_requested"])
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
|
||||
)
|
||||
self.assertEqual(counts["processed"], 0)
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
job.finish()
|
||||
self.api._scan_job = ScanJob()
|
||||
first = self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
|
||||
)
|
||||
self.assertEqual(first["pairs_added"], 1)
|
||||
self.assertEqual(
|
||||
self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
|
||||
)["unchanged"],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_legacy_found_outcome_is_not_counted_twice(self):
|
||||
result = self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "blank.png", {"parameters": "Negative prompt: blur"})]
|
||||
)
|
||||
self.assertEqual(result["processed"], 1)
|
||||
self.assertEqual(result["found"], 1)
|
||||
|
||||
def test_ambiguous_a1111_does_not_fall_back_to_legacy_guess(self):
|
||||
meta = {
|
||||
"parameters": (
|
||||
"cat\nNegative prompt: blur\nNegative prompt: noise"
|
||||
"\nSteps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
self.assertEqual(
|
||||
self.api._ingest_scanned_file(self.output_dir / "ambiguous.png", meta),
|
||||
"ambiguous",
|
||||
)
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_raw_png_workflow_is_stored_as_object_and_repeat_is_unchanged(self):
|
||||
path = self.output_dir / "workflow.png"
|
||||
workflow = {"nodes": [{"id": 1, "type": "PromptManagerPair"}]}
|
||||
metadata = {"prompt": json.dumps(graph()), "workflow": json.dumps(workflow)}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, metadata), "added")
|
||||
self.assertEqual(
|
||||
self.api.db.get_image_prompt_info(str(path))["workflow_data"], workflow
|
||||
)
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, metadata), "unchanged")
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Pair schema installation and restore failures use disposable databases only."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
|
||||
|
||||
class PairSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.models = []
|
||||
self.addCleanup(self.close_models)
|
||||
self.path = str(Path(self.temp.name) / "live.db")
|
||||
|
||||
def close_models(self):
|
||||
for model in self.models:
|
||||
model.close_all()
|
||||
|
||||
def model(self, path=None):
|
||||
model = PromptModel(path or self.path)
|
||||
self.models.append(model)
|
||||
return model
|
||||
|
||||
def populate(self, model):
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(id,text,hash,category,notes,run_count) VALUES"
|
||||
"(7,'positive','p','keep','notes',12)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(9,'negative','n')")
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images(id,prompt_id,image_path,filename,fil"
|
||||
"e_path) VALUES(4,7,'/tmp/image.png','image.png','/tmp/image.png')"
|
||||
)
|
||||
conn.execute("INSERT INTO tags(id,name) VALUES(3,'keep-tag')")
|
||||
conn.execute("INSERT INTO prompt_tags VALUES(7,3)")
|
||||
|
||||
def legacy(self):
|
||||
model = self.model(str(Path(self.temp.name) / "legacy.db"))
|
||||
self.populate(model)
|
||||
with model.get_connection() as conn:
|
||||
for table in ("image_prompt_pairs", "prompt_pair_tags", "prompt_pairs"):
|
||||
conn.execute(f"DROP TABLE IF EXISTS {table}")
|
||||
return model
|
||||
|
||||
def test_restore_scenarios_close_all_sqlite_handles(self):
|
||||
connections = []
|
||||
connect = sqlite3.connect
|
||||
|
||||
def tracked_connect(*args, **kwargs):
|
||||
connection = connect(*args, **kwargs)
|
||||
connections.append(connection)
|
||||
return connection
|
||||
|
||||
scenarios = (
|
||||
"test_pair_ddl_failure_rolls_back_and_retry_succeeds",
|
||||
"test_restore_legacy_wal_migrates_stage_and_preserves_safety_backup",
|
||||
"test_restore_migration_failure_keeps_original_and_source_untouched",
|
||||
"test_verification_rejects_partial_pair_schema_and_orphan_pair",
|
||||
)
|
||||
try:
|
||||
with patch.object(sqlite3, "connect", side_effect=tracked_connect):
|
||||
result = unittest.TestResult()
|
||||
unittest.TestSuite(type(self)(name) for name in scenarios).run(result)
|
||||
self.assertTrue(result.wasSuccessful(), result.errors + result.failures)
|
||||
for index, connection in enumerate(connections):
|
||||
with self.subTest(connection=index):
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
connection.execute("SELECT 1")
|
||||
finally:
|
||||
for connection in connections:
|
||||
connection.close()
|
||||
|
||||
def test_fresh_schema_constraints_and_foreign_keys(self):
|
||||
model = self.model()
|
||||
self.populate(model)
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id"
|
||||
") VALUES(2,7,9)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompt_pairs(positive_prompt_id) VALUES(7)")
|
||||
conn.execute("INSERT INTO prompt_pair_tags VALUES(2,3)")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance) VALUE"
|
||||
"S(2,4,'{}')"
|
||||
)
|
||||
for sql in (
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) V"
|
||||
"ALUES(7,9)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id) VALUES(7)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id) VALUES(404)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,rating) VALUES(9,6)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,archived) VALUES(9,2)",
|
||||
"DELETE FROM prompts WHERE id=9",
|
||||
"DELETE FROM prompt_pairs WHERE id=2",
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance) VALUE"
|
||||
"S(2,4,'{}')",
|
||||
):
|
||||
with self.subTest(sql=sql), self.assertRaises(sqlite3.IntegrityError):
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(sql)
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) V"
|
||||
"ALUES(9,7)"
|
||||
)
|
||||
conn.execute("DELETE FROM generated_images WHERE id=4")
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM image_prompt_pairs").fetchone()[0], 0
|
||||
)
|
||||
self.assertEqual(conn.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
|
||||
def test_legacy_upgrade_preserves_ids_metadata_images_counts_and_repeats(self):
|
||||
legacy = self.legacy()
|
||||
path = legacy.db_path
|
||||
legacy.close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
for _ in range(2):
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
tuple(
|
||||
conn.execute(
|
||||
"SELECT id,text,category,notes,run_count FROM prompts WHER"
|
||||
"E id=7"
|
||||
).fetchone()
|
||||
),
|
||||
(7, "positive", "keep", "notes", 12),
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT id FROM generated_images").fetchone()[0], 4
|
||||
)
|
||||
self.assertEqual(
|
||||
tuple(conn.execute("SELECT * FROM prompt_tags").fetchone()), (7, 3)
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0], 0
|
||||
)
|
||||
model.close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
|
||||
def test_pair_ddl_failure_rolls_back_and_retry_succeeds(self):
|
||||
with patch.object(
|
||||
PromptModel,
|
||||
"_create_pair_indexes",
|
||||
side_effect=sqlite3.OperationalError("injected index failure"),
|
||||
):
|
||||
with self.assertRaisesRegex(sqlite3.OperationalError, "injected"):
|
||||
self.model()
|
||||
with closing(sqlite3.connect(self.path)) as conn, conn:
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name IN ('prompt_pairs','pro"
|
||||
"mpt_pair_tags','image_prompt_pairs')"
|
||||
).fetchall(),
|
||||
[],
|
||||
)
|
||||
model = self.model()
|
||||
self.assertTrue(PromptModel.verify_database_file(model.db_path)[0])
|
||||
|
||||
def test_verification_rejects_partial_pair_schema_and_orphan_pair(self):
|
||||
model = self.model()
|
||||
self.populate(model)
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) V"
|
||||
"ALUES(7,9)"
|
||||
)
|
||||
model.close_all()
|
||||
with closing(sqlite3.connect(model.db_path)) as conn, conn:
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
conn.execute("UPDATE prompt_pairs SET negative_prompt_id=404")
|
||||
ok, reason = PromptModel.verify_database_file(model.db_path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("foreign key", reason.lower())
|
||||
with closing(sqlite3.connect(model.db_path)) as conn, conn:
|
||||
conn.execute("DROP TABLE prompt_pair_tags")
|
||||
ok, reason = PromptModel.verify_database_file(model.db_path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("pair", reason.lower())
|
||||
|
||||
def test_restore_migration_failure_keeps_original_and_source_untouched(self):
|
||||
live = self.model()
|
||||
self.populate(live)
|
||||
legacy = self.legacy()
|
||||
with live.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET text='original live text' WHERE id=7")
|
||||
with patch.object(
|
||||
PromptModel,
|
||||
"_migrate_prompt_pairs",
|
||||
side_effect=sqlite3.OperationalError("migration failed"),
|
||||
):
|
||||
with self.assertRaisesRegex(sqlite3.OperationalError, "migration failed"):
|
||||
live.restore_from_file(legacy.db_path)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT text FROM prompts WHERE id=7").fetchone()[0],
|
||||
"original live text",
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT run_count FROM prompts WHERE id=7").fetchone()[0],
|
||||
12,
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM generated_images").fetchone()[0], 1
|
||||
)
|
||||
with closing(sqlite3.connect(legacy.db_path)) as conn, conn:
|
||||
self.assertIsNone(
|
||||
conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name='prompt_pairs'"
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def test_restore_legacy_wal_migrates_stage_and_preserves_safety_backup(self):
|
||||
live = self.model()
|
||||
with live.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(text,hash) VALUES('original','original')")
|
||||
legacy = self.legacy()
|
||||
with legacy.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(text,hash) VALUES('wal-only','wal-only')")
|
||||
self.assertGreater(os.path.getsize(legacy.db_path + "-wal"), 0)
|
||||
backup = live.restore_from_file(legacy.db_path)
|
||||
self.assertTrue(Path(backup).is_file())
|
||||
with closing(sqlite3.connect(backup)) as conn, conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT text FROM prompts").fetchall(), [("original",)]
|
||||
)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM prompts").fetchone()[0], 3
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0], 0
|
||||
)
|
||||
self.assertTrue(PromptModel.verify_database_file(live.db_path)[0])
|
||||
|
||||
def test_restore_staged_verification_failure_does_not_replace_live_pages(self):
|
||||
live = self.model()
|
||||
self.populate(live)
|
||||
source = self.legacy()
|
||||
with patch.object(
|
||||
PromptModel,
|
||||
"verify_database_file",
|
||||
side_effect=[(True, "ok"), (False, "staged validation failed")],
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "staged validation failed"):
|
||||
live.restore_from_file(source.db_path)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT run_count FROM prompts WHERE id=7").fetchone()[0],
|
||||
12,
|
||||
)
|
||||
|
||||
def test_verification_rejects_missing_null_uniqueness(self):
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute("DROP INDEX idx_prompt_pairs_positive_only")
|
||||
ok, reason = PromptModel.verify_database_file(model.db_path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("uniqueness", reason)
|
||||
|
||||
def test_restore_round_trips_pair_metadata_tags_and_provenance(self):
|
||||
source = self.model(str(Path(self.temp.name) / "paired.db"))
|
||||
self.populate(source)
|
||||
with source.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id"
|
||||
",title,notes,run_count,archived) VALUES(6,7,9,'pair title','pair "
|
||||
"notes',19,1)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompt_pair_tags VALUES(6,3)")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(id,pair_id,image_id,provenance) VA"
|
||||
'LUES(8,6,4,\'{"execution":"kept"}\')'
|
||||
)
|
||||
live = self.model()
|
||||
live.restore_from_file(source.db_path)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
tuple(
|
||||
conn.execute(
|
||||
"SELECT id,title,notes,run_count,archived FROM prompt_pairs"
|
||||
).fetchone()
|
||||
),
|
||||
(6, "pair title", "pair notes", 19, 1),
|
||||
)
|
||||
self.assertEqual(
|
||||
tuple(conn.execute("SELECT * FROM prompt_pair_tags").fetchone()), (6, 3)
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT provenance FROM image_prompt_pairs WHERE id=8"
|
||||
).fetchone()[0],
|
||||
'{"execution":"kept"}',
|
||||
)
|
||||
|
||||
def test_restore_rejects_trigger_before_running_migrations(self):
|
||||
live = self.model()
|
||||
source = self.legacy()
|
||||
with source.get_connection() as conn:
|
||||
conn.execute(
|
||||
"CREATE TRIGGER dangerous AFTER INSERT ON prompts BEGIN DELETE FRO"
|
||||
"M prompts; END"
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "trigger"):
|
||||
live.restore_from_file(source.db_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Global tag maintenance preserves pair and standalone tag relationships."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
|
||||
class TestPairTagMaintenance(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(assert_closed, self, directory.name)
|
||||
self.db = PromptDatabase(os.path.join(directory.name, "pair-tags.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def pair(self, positive: str, tags: list) -> int:
|
||||
return self.db.save_prompt_pair(positive, "blurry", tags=tags)
|
||||
|
||||
def tags(self, pair_id: int) -> list:
|
||||
return self.db.get_prompt_pair(pair_id)["tags"]
|
||||
|
||||
def test_rename_to_existing_tag_preserves_pair_links_and_deduplicates(self):
|
||||
first = self.pair("portrait", ["old", "new", "keep"])
|
||||
second = self.pair("landscape", ["old"])
|
||||
third = self.pair("studio", ["unrelated"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["old", "new"])
|
||||
result = self.db.rename_tag_all_prompts("old", "new")
|
||||
self.assertEqual(result["affected_count"], 1)
|
||||
self.assertEqual(self.tags(first), ["keep", "new"])
|
||||
self.assertEqual(self.tags(second), ["new"])
|
||||
self.assertEqual(self.tags(third), ["unrelated"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["new"])
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["new"])["total"], 2)
|
||||
self.assertNotIn("old", self.db.get_all_tags())
|
||||
|
||||
def test_rename_pair_only_tag_to_new_name_preserves_links(self):
|
||||
pair = self.pair("portrait", ["old"])
|
||||
self.db.rename_tag_all_prompts("old", "new")
|
||||
self.assertEqual(self.tags(pair), ["new"])
|
||||
|
||||
def test_merge_pair_only_source_preserves_its_links(self):
|
||||
pair = self.pair("portrait", ["source"])
|
||||
other = self.pair("landscape", ["target"])
|
||||
self.db.merge_tags(["source"], "target")
|
||||
self.assertEqual(self.tags(pair), ["target"])
|
||||
self.assertEqual(self.tags(other), ["target"])
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["target"])["total"], 2)
|
||||
|
||||
def test_merge_multiple_sources_preserves_shared_and_unrelated_tags(self):
|
||||
pair = self.pair("portrait", ["first", "second", "target", "keep"])
|
||||
other = self.pair("landscape", ["second"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["first", "second"])
|
||||
self.db.merge_tags(["first", "second"], "target")
|
||||
self.assertEqual(self.tags(pair), ["keep", "target"])
|
||||
self.assertEqual(self.tags(other), ["target"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["target"])
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["target"])["total"], 2)
|
||||
|
||||
def test_self_rename_does_not_remove_tag_or_relationships(self):
|
||||
pair = self.pair("portrait", ["same"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["same"])
|
||||
self.db.rename_tag_all_prompts("same", "same")
|
||||
self.assertEqual(self.tags(pair), ["same"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["same"])
|
||||
|
||||
def test_merge_skips_target_even_when_it_is_in_source_list(self):
|
||||
pair = self.pair("portrait", ["target", "source"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["target", "source"])
|
||||
self.db.merge_tags(["target", "source", "target"], "target")
|
||||
self.assertEqual(self.tags(pair), ["target"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["target"])
|
||||
self.assertIn("target", self.db.get_all_tags())
|
||||
|
||||
def test_explicit_global_delete_removes_pair_tags_and_keeps_other_tags(self):
|
||||
pair = self.pair("portrait", ["remove", "keep"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["remove", "keep"])
|
||||
self.db.delete_tag_all_prompts("remove")
|
||||
self.assertEqual(self.tags(pair), ["keep"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["keep"])
|
||||
self.assertIsNotNone(self.db.get_prompt_pair(pair))
|
||||
|
||||
def test_pair_link_failure_rolls_back_standalone_merge_and_source_delete(self):
|
||||
pair = self.pair("portrait", ["source"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["source"])
|
||||
conn = self.db.model.get_connection()
|
||||
conn.execute(
|
||||
"CREATE TRIGGER reject_pair_tag_change BEFORE UPDATE "
|
||||
"ON prompt_pair_tags BEGIN SELECT RAISE(ABORT,'pair tag failure'); END"
|
||||
)
|
||||
conn.commit()
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.db.merge_tags(["source"], "target")
|
||||
self.assertEqual(self.tags(pair), ["source"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["source"])
|
||||
self.assertNotIn("target", self.db.get_all_tags())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Paired usage is counted at dequeue, including cached node executions."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils import usage_tracking
|
||||
from utils.prompt_graph import run_prompt_nodes
|
||||
from utils.usage_tracking import (
|
||||
PendingFirstUse,
|
||||
QueuedRuns,
|
||||
handle_queued_prompt,
|
||||
on_dequeue,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.test_prompt_pair_node import pair_graph
|
||||
except ImportError:
|
||||
from test_prompt_pair_node import pair_graph
|
||||
|
||||
|
||||
class TestPairUsageTracking(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(os.path.join(self.tmp.name, "pairs.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.queued = QueuedRuns()
|
||||
self.pair_queued = QueuedRuns()
|
||||
self.pending = PendingFirstUse()
|
||||
self.pair_pending = PendingFirstUse()
|
||||
|
||||
def submit(self, graph):
|
||||
return handle_queued_prompt(
|
||||
{"prompt": graph}, self.queued, pair_queued=self.pair_queued
|
||||
)
|
||||
|
||||
def dequeue(self, request):
|
||||
on_dequeue(
|
||||
request["prompt_id"],
|
||||
self.queued,
|
||||
lambda: self.db,
|
||||
self.pending,
|
||||
pair_queued=self.pair_queued,
|
||||
pair_pending=self.pair_pending,
|
||||
)
|
||||
|
||||
def node(self):
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
|
||||
node = PromptManagerPair.__new__(PromptManagerPair)
|
||||
node.db = self.db
|
||||
node.logger = logging.getLogger("test.pair_usage")
|
||||
node.pending_first_use = self.pending
|
||||
node.pending_pair_first_use = self.pair_pending
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.prompt_tracker = Mock()
|
||||
node.comfyui_integration = Mock()
|
||||
return node
|
||||
|
||||
def execute(self, graph, text="cat", negative="blur"):
|
||||
return self.node().encode_prompt(
|
||||
Mock(), text, negative_text=negative, prompt=graph, unique_id="1"
|
||||
)
|
||||
|
||||
def test_existing_pair_and_positive_count_on_every_dequeue_without_encoding(self):
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
for _ in range(3):
|
||||
self.dequeue(self.submit(pair_graph()))
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(pair["run_count"], 3)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 3
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 0
|
||||
)
|
||||
|
||||
def test_validation_rejection_never_counts_or_saves(self):
|
||||
self.submit(pair_graph())
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_new_pair_pending_uses_are_consumed_once_when_first_encoding_saves(self):
|
||||
graph = pair_graph()
|
||||
self.dequeue(self.submit(graph))
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 2)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 2
|
||||
)
|
||||
self.execute(graph)
|
||||
self.assertEqual(self.db.get_prompt_pair(pair["id"])["run_count"], 2)
|
||||
|
||||
def test_negative_only_changes_create_distinct_pair_counts_and_positive_counts(
|
||||
self,
|
||||
):
|
||||
self.db.save_prompt_pair("cat", "blur")
|
||||
self.db.save_prompt_pair("cat", "watermark")
|
||||
self.dequeue(self.submit(pair_graph()))
|
||||
self.dequeue(self.submit(pair_graph(negative="watermark")))
|
||||
self.assertEqual(
|
||||
[p["run_count"] for p in self.db.list_prompt_pairs()["pairs"]], [1, 1]
|
||||
)
|
||||
positive = self.db.get_prompt_by_hash(
|
||||
usage_tracking.generate_prompt_hash("cat")
|
||||
)
|
||||
self.assertEqual(positive["run_count"], 2)
|
||||
|
||||
def test_negative_output_slots_are_not_positive_prompt_sources(self):
|
||||
for slot in (1, 3):
|
||||
with self.subTest(slot=slot):
|
||||
self.assertEqual(
|
||||
run_prompt_nodes(pair_graph(positive_slot=slot, negative_slot=0)),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_mismatched_negative_does_not_count_unused_intrinsic_pair(self):
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
graph = pair_graph()
|
||||
graph["2"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "another negative"},
|
||||
}
|
||||
graph["5"]["inputs"]["negative"] = ["2", 0]
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_hash(usage_tracking.generate_prompt_hash("cat"))[
|
||||
"run_count"
|
||||
],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_unknown_batch_text_counts_each_execution_once_and_tracks_fifo(self):
|
||||
graph = pair_graph(text=["2", 0])
|
||||
graph["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.submit(graph)
|
||||
node = self.node()
|
||||
node.encode_prompt(
|
||||
Mock(), "batch cat", negative_text="blur", prompt=graph, unique_id="1"
|
||||
)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 1)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 1
|
||||
)
|
||||
self.assertTrue(
|
||||
node.prompt_tracker.set_current_prompt.call_args.kwargs["push_to_queue"]
|
||||
)
|
||||
|
||||
def test_standalone_pair_counts_cached_dequeues(self):
|
||||
pid = self.db.save_prompt_pair("cat", "")
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "cat", "negative_text": ""},
|
||||
}
|
||||
}
|
||||
self.dequeue(self.submit(graph))
|
||||
self.dequeue(self.submit(graph))
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 2)
|
||||
|
||||
def test_paired_string_outputs_through_encoders_keep_roles(self):
|
||||
from utils.prompt_graph import intrinsic_pair_nodes
|
||||
|
||||
graph = pair_graph()
|
||||
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 2]}}
|
||||
graph["3"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 3]}}
|
||||
graph["4"] = {"class_type": "Reroute", "inputs": {"value": ["2", 0]}}
|
||||
graph["5"]["inputs"].update(positive=["4", 0], negative=["3", 0])
|
||||
self.assertEqual(intrinsic_pair_nodes(graph), ["1"])
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
self.dequeue(self.submit(graph))
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 1)
|
||||
graph["3"]["inputs"]["text"] = ["1", 2]
|
||||
self.assertEqual(intrinsic_pair_nodes(graph), [])
|
||||
|
||||
def test_two_samplers_using_same_pair_count_one_run_and_dequeue_is_idempotent(self):
|
||||
graph = pair_graph()
|
||||
graph["6"] = {**graph["5"]}
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
request = self.submit(graph)
|
||||
self.dequeue(request)
|
||||
self.dequeue(request)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 1)
|
||||
|
||||
def test_new_negative_with_existing_positive_counts_pending_pair_only_once(self):
|
||||
self.db.save_prompt_pair("cat", "old negative")
|
||||
graph = pair_graph(negative="new negative")
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph, negative="new negative")
|
||||
pair = next(
|
||||
p
|
||||
for p in self.db.list_prompt_pairs()["pairs"]
|
||||
if p["negative_text"] == "new negative"
|
||||
)
|
||||
self.assertEqual(pair["run_count"], 1)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 1
|
||||
)
|
||||
|
||||
def test_unknown_negative_counts_at_execution_without_double_positive_count(self):
|
||||
graph = pair_graph(negative=["2", 0])
|
||||
graph["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph, negative="batch negative")
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 1)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 1
|
||||
)
|
||||
|
||||
def test_cyclic_unknown_and_oversized_pair_graphs_are_bounded(self):
|
||||
from utils.prompt_graph import intrinsic_pair_nodes
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
|
||||
self.assertEqual(intrinsic_pair_nodes(None), [])
|
||||
self.assertEqual(intrinsic_pair_nodes({str(n): {} for n in range(501)}), [])
|
||||
graph = pair_graph()
|
||||
graph["2"] = {"class_type": "Reroute", "inputs": {"value": ["2", 0]}}
|
||||
graph["5"]["inputs"]["positive"] = ["2", 0]
|
||||
self.assertEqual(intrinsic_pair_nodes(graph), [])
|
||||
self.assertIsNone(resolve_text_input(None, "1", "text"))
|
||||
self.assertIsNone(resolve_text_input({1: {}, "1": {}}, "1", "text"))
|
||||
self.assertIsNone(resolve_text_input(graph, "missing", "text"))
|
||||
self.assertIsNone(resolve_text_input(pair_graph(), "1", "text", max_depth=0))
|
||||
graph["1"]["inputs"]["text"] = ["1", 3]
|
||||
self.assertEqual(resolve_text_input(graph, "1", "text"), "blur")
|
||||
self.assertIsNone(resolve_text_input(graph, "1", "text", max_text_length=2))
|
||||
|
||||
def test_replacing_client_prompt_id_clears_obsolete_pair_records(self):
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
request = {"prompt_id": "same-id", "prompt": pair_graph()}
|
||||
handle_queued_prompt(request, self.queued, pair_queued=self.pair_queued)
|
||||
replacement = pair_graph()
|
||||
replacement["5"]["inputs"]["positive"] = ["1", 1]
|
||||
replacement["5"]["inputs"]["negative"] = ["1", 0]
|
||||
request = {"prompt_id": "same-id", "prompt": replacement}
|
||||
handle_queued_prompt(request, self.queued, pair_queued=self.pair_queued)
|
||||
self.dequeue(request)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 0)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Autocomplete shares vocabulary without mixing pair and prompt identities."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class TestPairVocabulary(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(assert_closed, self, directory.name)
|
||||
self.db = PromptDatabase(os.path.join(directory.name, "vocabulary.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def prompt(self, text: str, **kwargs) -> int:
|
||||
return self.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **kwargs
|
||||
)
|
||||
|
||||
def suggestions(self, prefix="", context=None, limit=20):
|
||||
return self.db.suggest_tags(prefix, context, limit)
|
||||
|
||||
def test_pair_only_categories_and_tags_are_available_without_component_metadata(
|
||||
self,
|
||||
):
|
||||
pair = self.db.get_prompt_pair(
|
||||
self.db.save_prompt_pair(
|
||||
"portrait", "blurry", category="Pairs", tags=["studio"]
|
||||
)
|
||||
)
|
||||
self.assertEqual(self.db.get_all_categories(), ["Pairs"])
|
||||
self.assertEqual(self.db.get_all_tags(), ["studio"])
|
||||
self.assertEqual(self.suggestions("stu"), [{"name": "studio", "count": 1}])
|
||||
for name in ("positive_prompt_id", "negative_prompt_id"):
|
||||
component = self.db.get_prompt_by_id(pair[name])
|
||||
self.assertIsNone(component["category"])
|
||||
self.assertEqual(component["tags"], [])
|
||||
|
||||
def test_categories_are_trimmed_deduplicated_and_include_archived_pairs(self):
|
||||
self.prompt("standalone", category=" Portrait ")
|
||||
pair = self.db.save_prompt_pair(
|
||||
"portrait", "", category="Portrait", tags=["archived"]
|
||||
)
|
||||
self.db.archive_prompt_pair(pair)
|
||||
self.db.save_prompt_pair("landscape", "", category=" Landscape ")
|
||||
self.db.save_prompt_pair("empty", "", category=" ")
|
||||
self.assertEqual(self.db.get_all_categories(), ["Landscape", "Portrait"])
|
||||
self.assertEqual(self.db.get_all_tags(), ["archived"])
|
||||
self.assertEqual(self.suggestions("arc"), [{"name": "archived", "count": 1}])
|
||||
|
||||
def test_counts_include_distinct_pair_and_prompt_references(self):
|
||||
self.prompt("portrait", tags=["shared", "legacy"])
|
||||
self.db.save_prompt_pair(
|
||||
"portrait", "first negative", tags=["shared", "paired"]
|
||||
)
|
||||
self.db.save_prompt_pair("portrait", "second negative", tags=["shared"])
|
||||
self.assertEqual(self.suggestions("sha"), [{"name": "shared", "count": 3}])
|
||||
self.assertEqual(
|
||||
self.suggestions("sha", ["paired"]), [{"name": "shared", "count": 1}]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("sha", ["legacy"]), [{"name": "shared", "count": 1}]
|
||||
)
|
||||
|
||||
def test_context_does_not_join_colliding_pair_and_prompt_ids(self):
|
||||
prompt = self.prompt("portrait", tags=["legacy-context", "legacy-only"])
|
||||
pair = self.db.save_prompt_pair(
|
||||
"portrait", "blurry", tags=["pair-context", "pair-only"]
|
||||
)
|
||||
self.assertEqual(prompt, pair)
|
||||
self.assertEqual(
|
||||
self.suggestions("", ["legacy-context"]),
|
||||
[{"name": "legacy-only", "count": 1}],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("", ["pair-context"]), [{"name": "pair-only", "count": 1}]
|
||||
)
|
||||
self.assertEqual(self.suggestions("", ["legacy-context", "pair-context"]), [])
|
||||
|
||||
def test_pair_context_matches_all_tags_case_insensitively(self):
|
||||
self.db.save_prompt_pair("portrait", "blurry", tags=["One", "two", "three"])
|
||||
self.db.save_prompt_pair("other", "blurry", tags=["One", "extra"])
|
||||
self.assertEqual(
|
||||
self.suggestions("TH", ["one", "TWO", "one"]),
|
||||
[{"name": "three", "count": 1}],
|
||||
)
|
||||
self.assertEqual(self.suggestions("", ["missing"]), [])
|
||||
|
||||
def test_pair_prefix_wildcards_are_literal_and_limit_is_preserved(self):
|
||||
self.db.save_prompt_pair(
|
||||
"portrait", "", tags=["tag_one", "tag_two", "tag%literal"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("tag_", limit=1), [{"name": "tag_one", "count": 1}]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("tag%"), [{"name": "tag%literal", "count": 1}]
|
||||
)
|
||||
self.assertEqual(self.suggestions("%"), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -54,6 +54,32 @@ class TestPositivePromptNodes(unittest.TestCase):
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["134"])
|
||||
|
||||
def test_focused_nodes_preserve_positive_roles_and_samplerless_fallback(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
|
||||
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
|
||||
"5": ksampler("1", "2"),
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
del graph["5"]
|
||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||
|
||||
def test_split_nodes_preserve_positive_roles_and_samplerless_fallback(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}},
|
||||
"2": {"class_type": "PromptManagerSplit", "inputs": {"text": "blur"}},
|
||||
"5": ksampler("1", "2"),
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
del graph["5"]
|
||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||
|
||||
def test_negative_only_prompt_manager_is_never_positive(self):
|
||||
graph = {
|
||||
"176": pm("blurry"),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Paired node encoding preserves recipe storage and output roles."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
from utils.usage_tracking import PendingFirstUse
|
||||
|
||||
|
||||
def pair_graph(text="cat", negative="blur", positive_slot=0, negative_slot=1):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": text, "negative_text": negative},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["9", 0],
|
||||
"positive": ["1", positive_slot],
|
||||
"negative": ["1", negative_slot],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPromptPairNode(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(os.path.join(self.tmp.name, "pairs.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.node = PromptManagerPair.__new__(PromptManagerPair)
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.pair_node")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = Mock(
|
||||
side_effect=lambda text: text + " LORA"
|
||||
)
|
||||
self.node.pending_first_use = PendingFirstUse()
|
||||
self.node.pending_pair_first_use = PendingFirstUse()
|
||||
self.clip = Mock()
|
||||
self.clip.encode_from_tokens_scheduled.side_effect = lambda tokens: (
|
||||
"conditioning",
|
||||
tokens,
|
||||
)
|
||||
self.clip.tokenize.side_effect = lambda text: text
|
||||
|
||||
def test_schema_has_positive_composition_and_four_ordered_outputs(self):
|
||||
schema = PromptManagerPair.INPUT_TYPES()
|
||||
self.assertEqual(list(schema["required"]), ["text", "negative_text", "clip"])
|
||||
self.assertTrue(schema["required"]["negative_text"][1]["multiline"])
|
||||
self.assertEqual(schema["required"]["negative_text"][1]["default"], "")
|
||||
for name in ("prepend_text", "append_text"):
|
||||
self.assertTrue(schema["optional"][name][1]["forceInput"])
|
||||
self.assertEqual(
|
||||
PromptManagerPair.RETURN_TYPES,
|
||||
("CONDITIONING", "CONDITIONING", "STRING", "STRING"),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
PromptManagerPair.IS_CHANGED(None, "cat", negative_text="blur"),
|
||||
PromptManagerPair.IS_CHANGED(None, "cat", negative_text="watermark"),
|
||||
)
|
||||
|
||||
def test_encodes_each_side_once_positive_composition_and_lora_only(self):
|
||||
graph = pair_graph()
|
||||
result = self.node.encode_prompt(
|
||||
self.clip,
|
||||
"cat",
|
||||
negative_text=" blur ",
|
||||
prepend_text=" trigger ",
|
||||
append_text=" detail ",
|
||||
category="Animals",
|
||||
tags="studio",
|
||||
prompt=graph,
|
||||
unique_id="1",
|
||||
)
|
||||
self.assertEqual(
|
||||
result,
|
||||
(
|
||||
("conditioning", "trigger cat detail LORA"),
|
||||
("conditioning", " blur "),
|
||||
"trigger cat detail LORA",
|
||||
" blur ",
|
||||
),
|
||||
)
|
||||
self.node._inject_lora_trigger_words.assert_called_once_with(
|
||||
"trigger cat detail"
|
||||
)
|
||||
self.assertEqual(self.clip.tokenize.call_count, 2)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(
|
||||
(
|
||||
pair["positive_text"],
|
||||
pair["negative_text"],
|
||||
pair["category"],
|
||||
pair["tags"],
|
||||
),
|
||||
("cat", "blur", "Animals", ["studio"]),
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["category"]
|
||||
)
|
||||
tracked = self.node.prompt_tracker.set_current_prompt.call_args.kwargs
|
||||
self.assertEqual(tracked["additional_data"]["pair_id"], pair["id"])
|
||||
self.assertEqual(tracked["additional_data"]["negative_text"], " blur ")
|
||||
self.assertEqual(tracked["additional_data"]["positive_storage_text"], "cat")
|
||||
|
||||
def test_empty_negative_encodes_empty_text_and_stores_null_component(self):
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="")
|
||||
self.assertEqual(result[3], "")
|
||||
self.assertIsNone(self.db.list_prompt_pairs()["pairs"][0]["negative_prompt_id"])
|
||||
|
||||
def test_invalid_clip_cannot_save_or_count(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.node.encode_prompt(None, "cat", negative_text="blur")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_persistence_failure_does_not_block_conditioning(self):
|
||||
self.node.db = Mock()
|
||||
self.node.db.save_prompt_pair.side_effect = RuntimeError("unavailable")
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="blur")
|
||||
self.assertEqual(result[2:], ("cat LORA", "blur"))
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_negative_output_used_as_positive_does_not_claim_positive_usage(self):
|
||||
self.node.encode_prompt(
|
||||
self.clip,
|
||||
"cat",
|
||||
negative_text="blur",
|
||||
prompt=pair_graph(positive_slot=1, negative_slot=0),
|
||||
unique_id="1",
|
||||
)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 0
|
||||
)
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_invalid_text_does_not_write_and_integration_failures_do_not_block(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.node.encode_prompt(self.clip, [], negative_text="blur")
|
||||
self.node.comfyui_integration.register_prompt.side_effect = RuntimeError(
|
||||
"metadata unavailable"
|
||||
)
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="blur")
|
||||
self.assertEqual(result[2:], ("cat LORA", "blur"))
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Conservative prompt-pair resolution contracts, independent of ComfyUI."""
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.prompt_pair_resolver import OutputAdapter, resolve_prompt_pair
|
||||
|
||||
|
||||
def graph(positive: str = "portrait", negative: str = "blurry") -> dict:
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPromptPairResolver(unittest.TestCase):
|
||||
def test_resolves_same_sampler_and_preserves_output_provenance(self):
|
||||
result = resolve_prompt_pair({"prompt": graph()})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], "portrait")
|
||||
self.assertEqual(result["negative"]["text"], "blurry")
|
||||
self.assertEqual(result["positive"]["provenance"]["node_id"], "1")
|
||||
self.assertEqual(result["positive"]["provenance"]["output_slot"], 0)
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
|
||||
def test_explicit_empty_negative_is_resolved_but_missing_is_absent(self):
|
||||
self.assertEqual(resolve_prompt_pair(graph(negative=""))["state"], "resolved")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(negative=""))["negative"]["text"], ""
|
||||
)
|
||||
source = graph()
|
||||
del source["3"]["inputs"]["negative"]
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["state"], "absent")
|
||||
self.assertEqual(result["positive"]["text"], "portrait")
|
||||
self.assertIsNone(result["negative"])
|
||||
|
||||
def test_multiple_samplers_are_ambiguous_even_when_texts_match(self):
|
||||
source = graph()
|
||||
source["4"] = {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": source["3"]["inputs"],
|
||||
}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(len(result["candidates"]), 2)
|
||||
self.assertIsNone(result["positive"])
|
||||
|
||||
def test_saver_anchor_selects_only_its_sampler(self):
|
||||
source = graph()
|
||||
source.update(
|
||||
{
|
||||
"4": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["2", 0], "negative": ["1", 0]},
|
||||
},
|
||||
"5": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0]}},
|
||||
"6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}},
|
||||
}
|
||||
)
|
||||
result = resolve_prompt_pair(source, output_node_id="6")
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "4")
|
||||
self.assertEqual(result["positive"]["text"], "blurry")
|
||||
|
||||
def test_unknown_anchor_transform_and_multistage_are_unresolved(self):
|
||||
source = graph()
|
||||
source["5"] = {
|
||||
"class_type": "MysteryImageTransform",
|
||||
"inputs": {"images": ["3", 0]},
|
||||
}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="5")["state"], "unresolved"
|
||||
)
|
||||
source["4"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {**source["3"]["inputs"], "latent_image": ["3", 0]},
|
||||
}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="4")["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_combines_unknown_encoders_and_sdxl_channels_are_not_guessed(self):
|
||||
for name in ("ConditioningCombine", "CLIPTextEncodeSDXL", "UnknownEncoder"):
|
||||
with self.subTest(name=name):
|
||||
source = graph()
|
||||
source["1"] = {
|
||||
"class_type": name,
|
||||
"inputs": {
|
||||
"text": "guess",
|
||||
"text_g": "global",
|
||||
"text_l": "local",
|
||||
"conditioning_1": ["2", 0],
|
||||
},
|
||||
}
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_prompt_manager_composition_and_string_slot_are_resolved(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "PromptManagerSplit",
|
||||
"inputs": {
|
||||
"text": "portrait",
|
||||
"prepend_text": " trigger ",
|
||||
"append_text": " detail ",
|
||||
},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["8", 1]
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["text"], "trigger portrait detail")
|
||||
self.assertIn(
|
||||
{"node_id": "8", "output_slot": 1}, result["positive"]["provenance"]["path"]
|
||||
)
|
||||
source["3"]["inputs"]["positive"] = ["8", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "trigger portrait detail"
|
||||
)
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
source["3"]["inputs"]["positive"] = ["1", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_concat_and_reroute_resolve_safe_linked_strings(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "portrait"}}
|
||||
source["9"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": ["8", 0], "string_b": ["8", 0], "delimiter": ", "},
|
||||
}
|
||||
source["10"] = {"class_type": "Reroute", "inputs": {"value": ["9", 0]}}
|
||||
source["1"]["inputs"]["text"] = ["10", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "portrait, portrait"
|
||||
)
|
||||
|
||||
def test_slot_adapter_does_not_conflate_two_outputs_of_one_node(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "FuturePair",
|
||||
"inputs": {"first": "positive", "second": "negative"},
|
||||
}
|
||||
source["3"]["inputs"] = {"positive": ["8", 0], "negative": ["8", 1]}
|
||||
adapters = {
|
||||
("FuturePair", 0): OutputAdapter("first", "conditioning"),
|
||||
("FuturePair", 1): OutputAdapter("second", "conditioning"),
|
||||
}
|
||||
result = resolve_prompt_pair(source, adapters=adapters)
|
||||
self.assertEqual(result["positive"]["text"], "positive")
|
||||
self.assertEqual(result["negative"]["text"], "negative")
|
||||
self.assertNotEqual(
|
||||
result["positive"]["provenance"]["output_slot"],
|
||||
result["negative"]["provenance"]["output_slot"],
|
||||
)
|
||||
|
||||
def test_prompt_manager_text_has_only_its_real_string_output_zero(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "text node", "prepend_text": "before"},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "before text node"
|
||||
)
|
||||
source["1"]["inputs"]["text"] = ["8", 1]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["3"]["inputs"]["positive"] = ["8", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_malformed_class_names_and_unknown_samplers_do_not_claim_absence(self):
|
||||
for name in ([], {}, None, "UnknownSampler"):
|
||||
source = graph()
|
||||
source["3"]["class_type"] = name
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_pair_adapter_composes_only_positive_and_retains_recipe_text(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": "positive",
|
||||
"negative_text": "negative",
|
||||
"prepend_text": "trigger",
|
||||
"append_text": "detail",
|
||||
},
|
||||
}
|
||||
source["3"]["inputs"] = {"positive": ["8", 0], "negative": ["8", 1]}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["text"], "trigger positive detail")
|
||||
self.assertEqual(result["positive"]["storage_text"], "positive")
|
||||
self.assertEqual(result["negative"]["text"], "negative")
|
||||
self.assertEqual(result["negative"]["storage_text"], "negative")
|
||||
source["1"]["inputs"]["text"] = ["8", 2]
|
||||
source["2"]["inputs"]["text"] = ["8", 3]
|
||||
source["3"]["inputs"] = {"positive": ["1", 0], "negative": ["2", 0]}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["storage_text"], "positive")
|
||||
self.assertEqual(result["negative"]["text"], "negative")
|
||||
source["3"]["inputs"]["negative"] = ["8", 3]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_linked_recipe_and_ordinary_clip_storage_values_are_explicit(self):
|
||||
source = graph()
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["storage_text"], "portrait")
|
||||
source["8"] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": "linked main"},
|
||||
}
|
||||
source["1"] = {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {"text": ["8", 0], "prepend_text": "before"},
|
||||
}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["text"], "before linked main")
|
||||
self.assertEqual(result["positive"]["storage_text"], "linked main")
|
||||
a1111 = resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
"portrait\nNegative prompt: blurry\n" "Steps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
)
|
||||
self.assertEqual(a1111["positive"]["storage_text"], "portrait")
|
||||
self.assertEqual(a1111["negative"]["storage_text"], "blurry")
|
||||
|
||||
def test_cycles_depth_visits_and_text_growth_are_bounded(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_visits=1)["state"], "unresolved"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_depth=0)["state"], "unresolved"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_text_length=3)["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_malformed_metadata_and_links_never_raise(self):
|
||||
for value in (
|
||||
None,
|
||||
[],
|
||||
"raw text",
|
||||
{},
|
||||
{"prompt": []},
|
||||
{"prompt": {"1": None}},
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertIn(
|
||||
resolve_prompt_pair(value)["state"], ("absent", "unresolved")
|
||||
)
|
||||
for link in (["missing", 0], ["1", -1], ["1", True], ["1", 17], ["1"], 5):
|
||||
source = graph()
|
||||
source["3"]["inputs"]["positive"] = link
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_a1111_explicit_parameters_and_empty_negative(self):
|
||||
for negative in ("bad anatomy\nblurry", ""):
|
||||
result = resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
f"portrait\nNegative prompt: {negative}\n"
|
||||
"Steps: 20, Sampler: Euler, Seed: 1"
|
||||
)
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["negative"]["text"], negative)
|
||||
self.assertEqual(result["positive"]["provenance"]["format"], "a1111")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"parameters": "portrait\nSteps: 20, Sampler: Euler"})[
|
||||
"state"
|
||||
],
|
||||
"absent",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"parameters": "unstructured arbitrary text"})["state"],
|
||||
"unresolved",
|
||||
)
|
||||
|
||||
def test_was_concat_preserves_configured_whitespace_and_rejects_bad_delimiters(
|
||||
self,
|
||||
):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "Text Concatenate",
|
||||
"inputs": {
|
||||
"text_a": " portrait ",
|
||||
"text_b": "",
|
||||
"text_c": "studio",
|
||||
"delimiter": ", ",
|
||||
},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "portrait, studio"
|
||||
)
|
||||
source["8"]["inputs"]["clean_whitespace"] = "false"
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], " portrait , studio"
|
||||
)
|
||||
source["8"]["inputs"]["delimiter"] = []
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["8"]["inputs"] = {1: "bad key"}
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_valid_empty_latent_and_reroute_but_not_unknown_or_cyclic_latents(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "EmptyLatentImage", "inputs": {}}
|
||||
source["9"] = {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}
|
||||
source["3"]["inputs"]["latent_image"] = ["9", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "resolved")
|
||||
source["9"]["inputs"]["value"] = ["9", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["3"]["inputs"]["latent_image"] = ["8", 0]
|
||||
source["8"]["class_type"] = "UnknownLatentTransform"
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["3"]["inputs"]["latent_image"] = None
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_cyclic_or_invalid_output_anchors_never_guess(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="8")["state"], "unresolved"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="missing")["state"], "unresolved"
|
||||
)
|
||||
source["8"] = {"class_type": "SaveImage", "inputs": {"images": ["3", 1]}}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="8")["state"], "unresolved"
|
||||
)
|
||||
source["8"] = {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="8", max_depth=1)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
|
||||
def test_malformed_reroute_empty_positive_and_duplicate_string_ids(self):
|
||||
source = graph(positive="")
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "Reroute",
|
||||
"inputs": {"value": ["1", 0], "input": ["2", 0]},
|
||||
}
|
||||
source["3"]["inputs"]["positive"] = ["8", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source[3] = source["3"]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_a1111_malformed_ambiguous_and_bounded_parameters(self):
|
||||
self.assertEqual(resolve_prompt_pair({"parameters": None})["state"], "absent")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"parameters": "x" * 20}, max_text_length=10)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(
|
||||
{"parameters": "\nNegative prompt: bad\nSteps: 20, Sampler: Euler"}
|
||||
)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
"portrait\nNegative prompt: bad\nNegative prompt: worse\n"
|
||||
"Steps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
)["state"],
|
||||
"ambiguous",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
"portrait\nNegative prompt: bad\n"
|
||||
"Steps: 20, Sampler: Euler\nextra text"
|
||||
)
|
||||
}
|
||||
)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
|
||||
def test_exponential_concat_text_is_bounded_before_building_a_large_string(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "xx"}}
|
||||
for index in range(9, 19):
|
||||
source[str(index)] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {
|
||||
"string_a": [str(index - 1), 0],
|
||||
"string_b": [str(index - 1), 0],
|
||||
},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["18", 0]
|
||||
result = resolve_prompt_pair(source, max_text_length=100)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertIn("length limit", result["reason"])
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_depth=1)["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_api_graph_is_authoritative_over_unrelated_parameters(self):
|
||||
source = graph()
|
||||
source["1"]["class_type"] = "UnknownEncoder"
|
||||
result = resolve_prompt_pair(
|
||||
{
|
||||
"prompt": source,
|
||||
"parameters": (
|
||||
"fallback\nNegative prompt: wrong\n" "Steps: 20, Sampler: Euler"
|
||||
),
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Reusable pairs own their metadata without changing shared prompt content."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class TestPromptPairs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(os.path.join(self.tmp.name, "pairs.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def save(self, positive="a cat", negative="blurry", **kwargs):
|
||||
return self.db.save_prompt_pair(positive, negative, **kwargs)
|
||||
|
||||
def test_pair_dedup_is_ordered_and_empty_negative_is_explicit(self):
|
||||
first = self.save()
|
||||
self.assertEqual(first, self.save(" A CAT ", " BLURRY "))
|
||||
self.assertNotEqual(first, self.save("blurry", "a cat"))
|
||||
self.assertNotEqual(first, self.save("a cat", "watermark"))
|
||||
empty = self.save("a cat", "")
|
||||
self.assertEqual(empty, self.save("a cat", " "))
|
||||
self.assertIsNone(self.db.get_prompt_pair(empty)["negative_prompt_id"])
|
||||
self.assertEqual(self.db.get_prompt_pair(empty)["negative_text"], "")
|
||||
self.assertNotEqual(empty, first)
|
||||
|
||||
def test_same_text_can_serve_both_roles_and_existing_metadata_survives(self):
|
||||
prompt = self.db.save_prompt(
|
||||
"a cat",
|
||||
category="original",
|
||||
tags=["old"],
|
||||
prompt_hash=generate_prompt_hash("a cat"),
|
||||
)
|
||||
pair = self.db.get_prompt_pair(
|
||||
self.save("a cat", "a cat", category="pair", tags=["new"])
|
||||
)
|
||||
self.assertEqual(pair["positive_prompt_id"], prompt)
|
||||
self.assertEqual(pair["negative_prompt_id"], prompt)
|
||||
self.assertEqual(pair["tags"], ["new"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["category"], "original")
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["old"])
|
||||
|
||||
def test_repeated_save_preserves_pair_metadata_and_explicit_update_changes_it(self):
|
||||
pid = self.save(
|
||||
title="Title",
|
||||
category="Portrait",
|
||||
tags=["studio", "studio"],
|
||||
rating=4,
|
||||
notes="keep",
|
||||
)
|
||||
self.assertEqual(pid, self.save(title="overwrite", tags=["bad"], rating=1))
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(
|
||||
(
|
||||
pair["title"],
|
||||
pair["category"],
|
||||
pair["tags"],
|
||||
pair["rating"],
|
||||
pair["notes"],
|
||||
),
|
||||
("Title", "Portrait", ["studio"], 4, "keep"),
|
||||
)
|
||||
self.assertTrue(
|
||||
self.db.update_prompt_pair_metadata(
|
||||
pid, title="", category="", tags=[], notes=""
|
||||
)
|
||||
)
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(
|
||||
(pair["title"], pair["category"], pair["tags"], pair["notes"]),
|
||||
("", "", [], ""),
|
||||
)
|
||||
self.assertEqual(pair["rating"], 4)
|
||||
self.assertFalse(self.db.update_prompt_pair_metadata(999999, title="missing"))
|
||||
self.assertFalse(self.db.update_prompt_pair_metadata(pid))
|
||||
|
||||
def test_list_filters_pagination_archive_and_pair_usage_are_independent(self):
|
||||
first = self.save(category="Animals", tags=["studio", "cat"])
|
||||
second = self.save("a dog", "blur", category="Animals", tags=["studio"])
|
||||
other = self.save("sky", "", category="Outdoor")
|
||||
result = self.db.list_prompt_pairs(category="Animals", tags=["studio"], limit=1)
|
||||
self.assertEqual(result["total"], 2)
|
||||
self.assertTrue(result["has_more"])
|
||||
self.assertEqual(len(result["pairs"]), 1)
|
||||
self.assertEqual(
|
||||
self.db.list_prompt_pairs(text="dog")["pairs"][0]["id"], second
|
||||
)
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["studio", "cat"])["total"], 1)
|
||||
self.assertTrue(self.db.archive_prompt_pair(first))
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 2)
|
||||
self.assertEqual(self.db.list_prompt_pairs(include_archived=True)["total"], 3)
|
||||
self.assertTrue(self.db.get_prompt_pair(first)["archived"])
|
||||
self.assertTrue(self.db.archive_prompt_pair(first, False))
|
||||
self.assertTrue(self.db.record_prompt_pair_use(other, 2))
|
||||
pair = self.db.get_prompt_pair(other)
|
||||
self.assertEqual(pair["run_count"], 2)
|
||||
self.assertIsNotNone(pair["last_used_at"])
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 0
|
||||
)
|
||||
|
||||
def test_failure_during_tag_save_rolls_back_pair_and_components(self):
|
||||
with patch.object(
|
||||
self.db, "_ensure_tags", side_effect=sqlite3.OperationalError("injected")
|
||||
):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
self.save(tags=["studio"])
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_concurrent_same_pair_returns_one_id_and_preserves_first_metadata(self):
|
||||
first = self.save(title="original")
|
||||
|
||||
def save(index):
|
||||
try:
|
||||
return self.save(title=f"writer {index}")
|
||||
finally:
|
||||
self.db.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
ids = list(executor.map(save, range(24)))
|
||||
self.assertEqual(set(ids), {first})
|
||||
self.assertEqual(self.db.get_prompt_pair(first)["title"], "original")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 1)
|
||||
|
||||
def test_concurrent_empty_negative_pair_creation_is_atomic(self):
|
||||
def save(_):
|
||||
try:
|
||||
return self.save(negative="")
|
||||
finally:
|
||||
self.db.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
ids = list(executor.map(save, range(12)))
|
||||
self.assertEqual(len(set(ids)), 1)
|
||||
self.assertEqual(len(self.db.search_prompts()), 1)
|
||||
|
||||
def test_component_edit_and_all_delete_paths_reject_dependencies_atomically(self):
|
||||
from database.operations import PairDependencyError
|
||||
|
||||
pair = self.db.get_prompt_pair(self.save())
|
||||
positive = pair["positive_prompt_id"]
|
||||
image = self.db.link_image_to_prompt(
|
||||
positive, os.path.join(self.tmp.name, "keep.png")
|
||||
)
|
||||
unused = self.db.save_prompt(
|
||||
"unused", category="remove", prompt_hash=generate_prompt_hash("unused")
|
||||
)
|
||||
self.db.update_prompt_metadata(positive, category="remove")
|
||||
for action in (
|
||||
lambda: self.db.update_prompt_text(positive, "replacement"),
|
||||
lambda: self.db.delete_prompt(positive),
|
||||
lambda: self.db.bulk_delete_prompts([unused, positive]),
|
||||
lambda: self.db.delete_prompts_by_category("remove"),
|
||||
):
|
||||
with self.subTest(action=action):
|
||||
with self.assertRaises(PairDependencyError):
|
||||
action()
|
||||
self.assertIsNotNone(self.db.get_prompt_by_id(unused))
|
||||
self.assertEqual(self.db.get_prompt_by_id(positive)["text"], "a cat")
|
||||
self.assertIsNotNone(self.db.get_image_by_id(image))
|
||||
self.assertTrue(self.db.delete_prompt_pair(pair["id"]))
|
||||
self.assertTrue(self.db.update_prompt_text(positive, "replacement"))
|
||||
|
||||
def test_prune_skips_members_even_in_archived_pairs_and_preserves_other_behavior(
|
||||
self,
|
||||
):
|
||||
pair = self.db.get_prompt_pair(self.save())
|
||||
self.db.archive_prompt_pair(pair["id"])
|
||||
unused = self.db.save_prompt(
|
||||
"unused", prompt_hash=generate_prompt_hash("unused")
|
||||
)
|
||||
self.assertEqual(self.db.prune_orphaned_prompts(), 1)
|
||||
self.assertIsNone(self.db.get_prompt_by_id(unused))
|
||||
self.assertIsNotNone(self.db.get_prompt_by_id(pair["negative_prompt_id"]))
|
||||
|
||||
def test_duplicate_cleanup_skips_referenced_group_without_losing_pairs(self):
|
||||
pair = self.db.get_prompt_pair(self.save())
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(text,category) VALUES(' A CAT ', 'duplicate')"
|
||||
)
|
||||
self.assertEqual(self.db.cleanup_duplicates(), 0)
|
||||
self.assertEqual(self.db.get_prompt_pair(pair["id"])["positive_text"], "a cat")
|
||||
self.assertEqual(len(self.db.search_prompts()), 3)
|
||||
|
||||
def test_image_association_preserves_provenance_and_pair_deletion_requires_archive(
|
||||
self,
|
||||
):
|
||||
from database.operations import PairDependencyError
|
||||
|
||||
pid = self.save()
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
image = self.db.link_image_to_prompt(
|
||||
pair["positive_prompt_id"], os.path.join(self.tmp.name, "render.png")
|
||||
)
|
||||
provenance = {
|
||||
"run_id": "abc",
|
||||
"negative_text": "actual expanded negative",
|
||||
"positive_text": "trigger a cat",
|
||||
}
|
||||
association = self.db.link_image_to_pair(pid, image, provenance)
|
||||
self.assertEqual(
|
||||
association,
|
||||
self.db.link_image_to_pair(
|
||||
pid, image, dict(reversed(list(provenance.items())))
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_image_prompt_pairs(image)[0]["provenance"], provenance
|
||||
)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["image_count"], 1)
|
||||
with self.assertRaises(PairDependencyError):
|
||||
self.db.delete_prompt_pair(pid)
|
||||
self.assertTrue(self.db.archive_prompt_pair(pid))
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(99999, image, provenance)
|
||||
self.assertTrue(self.db.delete_image(image))
|
||||
self.assertEqual(self.db.get_image_prompt_pairs(image), [])
|
||||
self.assertTrue(self.db.delete_prompt_pair(pid))
|
||||
self.assertIsNotNone(self.db.get_prompt_by_id(pair["positive_prompt_id"]))
|
||||
|
||||
def test_pair_validates_values_before_writing(self):
|
||||
for positive, negative, kwargs in (
|
||||
("", "blur", {}),
|
||||
("cat", None, {}),
|
||||
("cat", "blur", {"rating": 6}),
|
||||
("cat", "blur", {"tags": "one"}),
|
||||
):
|
||||
with self.subTest(positive=positive, negative=negative, kwargs=kwargs):
|
||||
with self.assertRaises((TypeError, ValueError)):
|
||||
self.save(positive, negative, **kwargs)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
for kwargs in ({"limit": 0}, {"offset": -1}, {"limit": True}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.list_prompt_pairs(**kwargs)
|
||||
self.assertIsNone(self.db.get_prompt_pair(99999))
|
||||
self.assertFalse(self.db.delete_prompt_pair(99999))
|
||||
|
||||
def test_connection_helpers_leave_commit_and_rollback_to_the_caller(self):
|
||||
conn = self.db.model.get_connection()
|
||||
|
||||
def rollback_transaction():
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
self.db._save_pair(conn, "transient", "temporary", tags=["tag"])
|
||||
self.assertRaisesRegex(RuntimeError, "own transaction", self.save)
|
||||
raise RuntimeError("rollback")
|
||||
|
||||
self.assertRaisesRegex(RuntimeError, "rollback", rollback_transaction)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_metadata_update_failure_rolls_back_prior_fields_and_tags(self):
|
||||
pid = self.save(title="keep", tags=["old"])
|
||||
with patch.object(
|
||||
self.db, "_ensure_tags", side_effect=sqlite3.OperationalError("injected")
|
||||
):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
self.db.update_prompt_pair_metadata(pid, title="replace", tags=["new"])
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(pair["title"], "keep")
|
||||
self.assertEqual(pair["tags"], ["old"])
|
||||
|
||||
def test_invalid_associations_metadata_and_missing_mutations_are_safe(self):
|
||||
pid = self.save()
|
||||
other = self.db.save_prompt("other", prompt_hash=generate_prompt_hash("other"))
|
||||
image = self.db.link_image_to_prompt(
|
||||
other, os.path.join(self.tmp.name, "other.png")
|
||||
)
|
||||
for provenance in ([], {"number": float("nan")}, {"object": object()}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(pid, image, provenance)
|
||||
with self.assertRaisesRegex(ValueError, "positive prompt"):
|
||||
self.db.link_image_to_pair(pid, image, {})
|
||||
self.assertEqual(self.db.get_image_prompt_pairs(image), [])
|
||||
self.assertFalse(self.db.archive_prompt_pair(999999))
|
||||
self.assertFalse(self.db.record_prompt_pair_use(999999))
|
||||
for times in (0, True):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.record_prompt_pair_use(pid, times)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_prompt_pair(pid, "yes")
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.update_prompt_pair_metadata(pid, title=[])
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.list_prompt_pairs(text=[])
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Split changes presentation while inheriting Focused execution and storage."""
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
from prompt_manager import PromptManagerFocused, PromptManagerSplit
|
||||
|
||||
|
||||
class TestSplitNode(unittest.TestCase):
|
||||
def test_split_preserves_focused_schema_encoding_and_change_detection(self):
|
||||
self.assertTrue(issubclass(PromptManagerSplit, PromptManagerFocused))
|
||||
self.assertEqual(
|
||||
PromptManagerSplit.INPUT_TYPES(), PromptManagerFocused.INPUT_TYPES()
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptManagerSplit.RETURN_TYPES, PromptManagerFocused.RETURN_TYPES
|
||||
)
|
||||
self.assertEqual(PromptManagerSplit.FUNCTION, PromptManagerFocused.FUNCTION)
|
||||
self.assertIs(
|
||||
PromptManagerSplit.encode_prompt, PromptManagerFocused.encode_prompt
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptManagerSplit.IS_CHANGED(None, "cat", category="Animals"),
|
||||
PromptManagerFocused.IS_CHANGED(None, "cat", category="Animals"),
|
||||
)
|
||||
|
||||
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
|
||||
node = PromptManagerSplit.__new__(PromptManagerSplit)
|
||||
node.logger = logging.getLogger("test.split")
|
||||
node.comfyui_integration = unittest.mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
|
||||
clip = unittest.mock.Mock()
|
||||
graph = {"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}}}
|
||||
conditioning, text = node.encode_prompt(
|
||||
clip,
|
||||
"cat",
|
||||
category="Animals",
|
||||
tags="studio",
|
||||
prepend_text="trigger",
|
||||
append_text="detailed",
|
||||
prompt=graph,
|
||||
unique_id="1",
|
||||
)
|
||||
self.assertEqual(text, "trigger cat detailed")
|
||||
clip.tokenize.assert_called_once_with(text)
|
||||
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
|
||||
tracked = node._track_prompt_execution.call_args.kwargs
|
||||
self.assertEqual(tracked["text"], "cat")
|
||||
self.assertEqual(tracked["category"], "Animals")
|
||||
self.assertIn("studio", tracked["tags"])
|
||||
self.assertEqual(tracked["prompt_graph"], graph)
|
||||
self.assertEqual(tracked["unique_id"], "1")
|
||||
@@ -126,6 +126,26 @@ class TestQueueHook(DbTestCase):
|
||||
self.assertEqual(self._runs(positive), 1)
|
||||
self.assertEqual(self._runs(negative), 0)
|
||||
|
||||
def test_focused_queued_runs_count_only_positive_even_when_cached(self):
|
||||
positive, negative = self._save("a cat"), self._save("blurry")
|
||||
graph = workflow()
|
||||
for node_id in ("134", "176"):
|
||||
graph[node_id]["class_type"] = "PromptManagerFocused"
|
||||
self._queue(graph)
|
||||
self._queue(graph)
|
||||
self.assertEqual(self._runs(positive), 2)
|
||||
self.assertEqual(self._runs(negative), 0)
|
||||
|
||||
def test_split_queued_runs_count_only_positive_even_when_cached(self):
|
||||
positive, negative = self._save("a cat"), self._save("blurry")
|
||||
graph = workflow()
|
||||
for node_id in ("134", "176"):
|
||||
graph[node_id]["class_type"] = "PromptManagerSplit"
|
||||
self._queue(graph)
|
||||
self._queue(graph)
|
||||
self.assertEqual(self._runs(positive), 2)
|
||||
self.assertEqual(self._runs(negative), 0)
|
||||
|
||||
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
|
||||
positive = self._save("a cat")
|
||||
self._queue(workflow())
|
||||
|
||||
@@ -263,5 +263,35 @@ class TestConfiguration(IsolatedLoggerTestCase):
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
|
||||
class TestConsoleNoise(IsolatedLoggerTestCase):
|
||||
"""ComfyUI's console only sees warnings unless the level is DEBUG."""
|
||||
|
||||
def _console_handler(self) -> logging.StreamHandler:
|
||||
return next(
|
||||
h
|
||||
for h in logging.getLogger("prompt_manager").handlers
|
||||
if type(h) is logging.StreamHandler
|
||||
)
|
||||
|
||||
def test_records_do_not_propagate_to_comfyui_root_logger(self) -> None:
|
||||
self.assertFalse(logging.getLogger("prompt_manager").propagate)
|
||||
|
||||
def test_console_hides_info_at_the_default_level(self) -> None:
|
||||
self.manager.update_config({"console_logging": True})
|
||||
self.assertEqual(self._console_handler().level, logging.WARNING)
|
||||
self.logger.info("startup chatter")
|
||||
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "INFO")
|
||||
log_file = pathlib.Path(self.log_dir) / "prompt_manager.log"
|
||||
self.assertIn("startup chatter", log_file.read_text())
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
def test_debug_level_opens_the_console(self) -> None:
|
||||
self.manager.update_config({"console_logging": True, "level": "DEBUG"})
|
||||
self.assertEqual(self._console_handler().level, logging.DEBUG)
|
||||
self.manager.update_config({"level": "INFO"})
|
||||
self.assertEqual(self._console_handler().level, logging.WARNING)
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
test('browser coverage maps nested JS/MJS and excludes unrelated and traversal paths', async t => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'pm-browser-coverage-'));
|
||||
const previous = process.env.PM_JS_COVERAGE;
|
||||
process.env.PM_JS_COVERAGE = directory;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.PM_JS_COVERAGE;
|
||||
else process.env.PM_JS_COVERAGE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const urls = [
|
||||
'/prompt_manager/js/gallery.js',
|
||||
'/prompt_manager/js/shared/controls/button.mjs?v=1',
|
||||
'/prompt_manager/js/shared/panel.js',
|
||||
'/scripts/app.js', '/prompt_manager/js/../private.js',
|
||||
'/prompt_manager/js/shared/theme.css',
|
||||
];
|
||||
await saveCoverage({ coverage: { stopJSCoverage: async () => urls.map(url => ({ url: `http://127.0.0.1:8765${url}`, functions: [] })) } }, 'chromium');
|
||||
const report = JSON.parse(readFileSync(join(directory, readdirSync(directory)[0]), 'utf8'));
|
||||
const root = fileURLToPath(new URL('../../web/js/', import.meta.url));
|
||||
assert.deepEqual(report.result.map(entry => fileURLToPath(entry.url)), [join(root, 'gallery.js'), join(root, 'shared/controls/button.mjs'), join(root, 'shared/panel.js')]);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
|
||||
const output = execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js',
|
||||
'test', '--config', 'tools/vendor/comfyui.config.mjs', '--list'], {
|
||||
cwd: root, encoding: 'utf8', env: { ...process.env,
|
||||
PM_COMFY_ROOT: '/unused-pinned-checkout', PM_COMFY_URL: 'http://127.0.0.1:8199/' },
|
||||
});
|
||||
assert.match(output, /\[classic-start\]/);
|
||||
assert.match(output, /\[nodes2-start\]/);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
|
||||
|
||||
async function fixture(t, program) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'pm-host-test-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const checkout = join(directory, 'host');
|
||||
const repository = join(directory, 'plugin');
|
||||
await mkdir(checkout);
|
||||
await mkdir(repository);
|
||||
await writeFile(join(repository, '__init__.py'), '');
|
||||
execFileSync('git', ['init', '--quiet', repository]);
|
||||
execFileSync('git', ['-C', repository, 'add', '__init__.py']);
|
||||
await writeFile(join(repository, 'config.json'), 'private-user-config');
|
||||
await writeFile(join(repository, 'prompts.db'), 'private-user-database');
|
||||
await writeFile(join(checkout, 'main.py'), program);
|
||||
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
git(['init', '--quiet']);
|
||||
git(['add', 'main.py']);
|
||||
git(['-c', 'user.name=PromptManager Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
|
||||
return { checkout, repository, python: process.execPath, port: 8199,
|
||||
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
|
||||
}
|
||||
|
||||
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
|
||||
|
||||
test('PromptManager adapter isolates database, configuration and monitored directories', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
const host = await launchComfyHost(options);
|
||||
t.after(() => host.stop());
|
||||
await once(host.child.stdout, 'data');
|
||||
const args = host.child.spawnargs;
|
||||
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
|
||||
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
|
||||
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
|
||||
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'promptmanager');
|
||||
const installed = join(host.stateDirectory, 'custom_nodes/promptmanager');
|
||||
assert.notEqual(await realpath(installed), await realpath(options.repository));
|
||||
assert.equal(await readFile(join(installed, '__init__.py'), 'utf8'), '');
|
||||
await assert.rejects(access(join(installed, 'prompts.db')), { code: 'ENOENT' });
|
||||
assert.equal(JSON.parse(await readFile(join(installed, 'config.json'), 'utf8')).database.default_path, join(host.stateDirectory, 'prompts.db'));
|
||||
await writeFile(join(installed, 'prompts.db'), 'test data');
|
||||
assert.equal(await readFile(join(options.repository, 'prompts.db'), 'utf8'), 'private-user-database');
|
||||
assert.equal(await readFile(join(options.repository, 'config.json'), 'utf8'), 'private-user-config');
|
||||
const config = JSON.parse(await readFile(join(installed, 'config.json'), 'utf8'));
|
||||
assert.equal(config.gallery.monitoring.enabled, false);
|
||||
assert.deepEqual(config.gallery.monitoring.directories, [join(host.stateDirectory, 'output')]);
|
||||
assert.equal(config.web_ui.result_timeout, 0);
|
||||
await access(join(host.stateDirectory, 'user'));
|
||||
await host.stop();
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
await access(join(options.checkout, 'main.py'));
|
||||
await access(join(options.repository, '__init__.py'));
|
||||
});
|
||||
|
||||