Compare commits

..
Author SHA1 Message Date
Vito Sansevero e4c128f398 fix(test): own /dev/shm reset scratch by a flock, not a pid (#419)
A browser server inside an --ipc=host container shares the host's /dev/shm
but not its PID namespace, so the pid probe saw live servers' scratch folders
as orphans and deleted them. A live owner now holds a flock on owner.lock in
its folder; the kernel drops it when the owner dies, in any namespace. The
cleaner removes only held-named folders whose lock is free and leaves legacy
and staging folders alone. Without flock (Windows) the scratch stays on disk.
2026-10-28 15:39:38 -07:00
238 changed files with 1087 additions and 24516 deletions
+5 -56
View File
@@ -21,19 +21,15 @@ env:
PYTHONFAULTHANDLER: "1"
# Time limit rule as in test.yml: ceil(1.5 x the slowest of three recent green runs on main) + 3
# minutes, never under 5, timed as whole-job wall time. One matrix job, so the slowest leg of each
# run counts (tests/vendor/ci-workflow.test.mjs recomputes it, #493).
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
# after three green runs.
# minutes, never under 5. Measured 2026-10-26 in test.yml on runs 40257217681 (3be0ada),
# 40240706568 (fb9134f), 40228167328 (f9835aa), before these legs moved here in #420:
# slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
# (macos 6m14s-8m30s, windows 11m42s-15m54s)
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 15
timeout-minutes: 27
strategy:
fail-fast: false
matrix:
@@ -55,52 +51,5 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
# The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms
# and a test database's create-to-delete about 111 ms; on the D: work
# disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their
# databases under TEMP, so it moves to a folder on D: (measured in #487,
# run 40915866444). The runner's TEMP was an 8.3 short name
# (C:\Users\RUNNER~1\...), so the new one is spelled by its short name too
# and paths still have to be resolved. D: makes no short names by default
# (run 40922502209), so they are switched on for it first.
- name: Keep test files on the work disk (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
fsutil 8dot3name set D: 0
$folder = New-Item -ItemType Directory -Force -Path (Join-Path $env:RUNNER_TEMP "PromptManagerTests")
$short = (New-Object -ComObject Scripting.FileSystemObject).GetFolder($folder.FullName).ShortPath
Write-Output "test TEMP: $short (long name $($folder.FullName))"
if ($short -eq $folder.FullName) { Write-Output "::warning::D: gave the test TEMP no 8.3 short name" }
"TEMP=$short" >> $env:GITHUB_ENV
"TMP=$short" >> $env:GITHUB_ENV
- name: Run tests
env:
PM_TEST_SLOWEST: "40"
run: python -m tests.run_suite -v
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
# suite never skips the native proof.
native-trash:
name: native trash on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 6
strategy:
fail-fast: false
matrix:
os: [macos-latest, macos-15-intel]
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Native trash proof
timeout-minutes: 4
env:
PM_NATIVE_TRASH: macos
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
+2 -2
View File
@@ -28,8 +28,8 @@ env:
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
# Windows and macOS moved to portability.yml (#420), which has its own measured limit (#493);
# this Linux-only matrix keeps 27 minutes until it is re-measured.
# Windows and macOS moved to portability.yml (#420), which keeps this 27-minute limit; this
# Linux-only matrix keeps it too until it is re-measured.
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
# perf budget 30s / 29s / 27s, black and flake8 39s / 37s / 39s,
+4 -9
View File
@@ -36,11 +36,6 @@ concurrency:
# 12m53s, node units and the viewer suite before 11.8m of workflows), so coverage is the tight one
# in its 18 minutes. This holds only when the npm cache hits: a cold cache adds up to 5m to npm ci,
# and then the job limit can still end the job before the step limit does.
# #473: firefox/webkit shard 1/2 reached its 18-minute step limit (queue run 40866252975: webkit 622 tests
# passed in 17m56s, 48.5 worker-minutes vs 28.0 in shard 2/2; recent shard 1/2 jobs 13.7m-18.9m, shard 2/2
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
# re-measured after three green main runs.
jobs:
frontend-coverage:
name: frontend coverage (shard ${{ matrix.shard }}/3)
@@ -180,7 +175,7 @@ jobs:
retention-days: 14
browser-workflows:
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/3)
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
# Merge queue, main and manual runs only (#420); pull requests run the Chromium/touch coverage shards.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
@@ -199,7 +194,7 @@ jobs:
fail-fast: false
matrix:
browser: [firefox, webkit]
shard: [1, 2, 3]
shard: [1, 2]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -226,9 +221,9 @@ jobs:
path: ~/.npm
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
- run: npm ci --prefix tools/vendor --ignore-scripts
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/3
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
timeout-minutes: 18
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/3
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
# Traces and the blob report (test-results/blob/) of a failed, timed-out or cancelled step.
- name: Upload browser traces
if: always()
-4
View File
@@ -214,7 +214,3 @@ coverage/
# Old library files a Move library renamed (never deleted; #249 S6b)
*.moved-to-*
# Per-install key behind the workspace scopes and cursors (#430)
workspace-scope.key
.workspace-scope.key.*.tmp
+58 -1063
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -40,7 +40,6 @@ from .annotation_schema import (
METADATA_TABLE,
)
from .autotag_schema import AUTOTAG_TABLE
from .image_removal_schema import clear_marks
from .library_commands import _UUID, LibraryCommandError, _validate_epoch
from .models import utc_now_iso
from .vocabulary import validate_vocabulary_tag_name
@@ -237,9 +236,6 @@ class AnnotationOperations:
@staticmethod
def _annotation_owner(conn: sqlite3.Connection, locator: str, now: str) -> str:
# A file with image tags or Needs review state is in the library: a
# removal mark never hides it (#480).
clear_marks(conn, [locator])
row = conn.execute(_OWNER, (locator,)).fetchone()
if row is not None:
return row[0]
+26 -58
View File
@@ -7,11 +7,6 @@ from typing import Callable, ContextManager, Iterator
try:
from ..utils.execution_history_events import RunFinish, encode_command
from ..utils.hashing import (
generate_prompt_hash,
stored_prompt_text,
stored_prompt_text_sql,
)
from ..utils.execution_contexts import (
CAPTURE_REASONS,
ContextBundle,
@@ -20,11 +15,6 @@ try:
)
except ImportError:
from utils.execution_history_events import RunFinish, encode_command
from utils.hashing import (
generate_prompt_hash,
stored_prompt_text,
stored_prompt_text_sql,
)
from utils.execution_contexts import (
CAPTURE_REASONS,
ContextBundle,
@@ -144,8 +134,7 @@ def _insert_exact(
def _role_hints(role: str) -> str:
return (
f"SELECT r.{role}_prompt_id, tx.text_value, "
f"{stored_prompt_text_sql('tx.text_value')} "
f"SELECT r.{role}_prompt_id, tx.text_value "
"FROM execution_run_contexts r "
"JOIN execution_contexts c ON c.context_id = r.context_id "
f"JOIN execution_prompt_snapshots s ON s.snapshot_id = c.{role}_snapshot_id "
@@ -157,78 +146,57 @@ def _role_hints(role: str) -> str:
# Set form of the read-time prompt-hint rule in get_execution_contexts():
# a stored role hint is live only in the current storage epoch, and only when
# the hinted prompt is the one match of _prompt_hint: by text (the raw
# snapshot text or its stored form, outer whitespace stripped, #438), or, only
# when no prompt has either text, by dedup hash (case folded too, #460).
# The hash is a fallback, so a hint #438 kept is never made ambiguous.
# Needs register_prompt_functions (pm_prompt_hash). The hint rows are the
# outer loop (CROSS JOIN), so each prompts lookup is by index.
# its exact raw snapshot text matches exactly one prompt, which is the hinted
# one (_prompt_hint). Evaluated once per statement, never per prompt row.
# Defines the CTE ``live_hint_prompts(prompt_id)``; prefix it with WITH.
LIVE_HINT_CTES = (
"stored_hints(prompt_id, raw, stored) AS ("
"stored_hints(prompt_id, raw) AS ("
+ _role_hints("positive")
+ " UNION "
+ _role_hints("negative")
+ "), hint_matches(prompt_id, raw, stored, match_id) AS ("
"SELECT h.prompt_id, h.raw, h.stored, p.id FROM stored_hints h "
"CROSS JOIN prompts p ON p.text COLLATE BINARY IN (h.raw, h.stored) "
"UNION ALL "
"SELECT h.prompt_id, h.raw, h.stored, p.id FROM stored_hints h "
"CROSS JOIN prompts p ON p.hash = pm_prompt_hash(h.raw) "
"WHERE NOT EXISTS (SELECT 1 FROM prompts q "
"WHERE q.text COLLATE BINARY IN (h.raw, h.stored))"
+ "), unique_hint_texts(prompt_id, raw) AS ("
"SELECT MIN(id), text COLLATE BINARY FROM prompts "
"WHERE text COLLATE BINARY IN (SELECT raw FROM stored_hints) "
"GROUP BY text COLLATE BINARY HAVING COUNT(*) = 1"
"), live_hint_prompts(prompt_id) AS ("
"SELECT prompt_id FROM hint_matches GROUP BY prompt_id, raw, stored "
"HAVING COUNT(*) = 1 AND MIN(match_id) = prompt_id)"
"SELECT h.prompt_id FROM stored_hints h JOIN unique_hint_texts u "
"ON u.prompt_id = h.prompt_id AND u.raw = h.raw COLLATE BINARY)"
)
LIVE_HINT_PROMPT_IDS_SQL = (
"WITH " + LIVE_HINT_CTES + " SELECT DISTINCT prompt_id FROM live_hint_prompts"
)
_BY_TEXT = "SELECT id FROM prompts WHERE text COLLATE BINARY IN (?,?) LIMIT 2"
def _text_matches(conn: sqlite3.Connection, raw: str) -> list:
return [row[0] for row in conn.execute(_BY_TEXT, (raw, stored_prompt_text(raw)))]
def _prompt_hint(conn: sqlite3.Connection, raw: str | None) -> int | None:
"""The one prompt that stores ``raw`` (#438), else the one it counts toward.
By text first: ``raw`` or ``raw.strip()`` (the exact form keeps hints of
older rows that kept outer whitespace). Only when no prompt has either
text, by the dedup hash, which also folds case (#460).
"""
if raw is None:
return None
matches = _text_matches(conn, raw)
if not matches:
matches = [
row[0]
for row in conn.execute(
"SELECT id FROM prompts WHERE hash=? LIMIT 2",
(generate_prompt_hash(raw),),
)
]
return matches[0] if len(matches) == 1 else None
rows = conn.execute(
"SELECT id FROM prompts WHERE text=? COLLATE BINARY LIMIT 2", (raw,)
).fetchall()
return rows[0][0] if len(rows) == 1 else None
def _hints(
conn: sqlite3.Connection, positive: str | None, negative: str | None
) -> tuple:
pos_id, neg_id = _prompt_hint(conn, positive), _prompt_hint(conn, negative)
stored = None if negative is None else stored_prompt_text(negative)
pair_id = None
# A known negative that names no prompt because several share its text
negative_ambiguous = (
stored is not None and neg_id is None and bool(_text_matches(conn, negative))
negative is not None
and neg_id is None
and conn.execute(
"SELECT 1 FROM prompts WHERE text=? COLLATE BINARY LIMIT 1", (negative,)
).fetchone()
is not None
)
if pos_id is not None and stored is not None and not negative_ambiguous:
if pos_id is not None and negative is not None and not negative_ambiguous:
rows = conn.execute(
"SELECT pp.id FROM prompt_pairs pp "
"SELECT pp.id FROM prompt_pairs pp LEFT JOIN prompts n ON "
"n.id=pp.negative_prompt_id "
"WHERE pp.positive_prompt_id=? AND ((pp.negative_prompt_id IS NULL AND "
"?='') OR pp.negative_prompt_id=?) LIMIT 2",
(pos_id, stored, neg_id),
"?='') "
"OR (pp.negative_prompt_id=? AND n.text=? COLLATE BINARY)) LIMIT 2",
(pos_id, negative, neg_id, negative),
).fetchall()
if len(rows) == 1:
pair_id = rows[0][0]
+7 -3
View File
@@ -23,10 +23,10 @@ from pathlib import PurePosixPath, PureWindowsPath
from urllib.parse import quote
try:
from ..utils.hashing import register_prompt_functions
from ..utils.hashing import generate_prompt_hash
from .models import normalize_image_path
except ImportError:
from utils.hashing import register_prompt_functions
from utils.hashing import generate_prompt_hash
from database.models import normalize_image_path
logger = logging.getLogger("prompt_manager.database.health")
@@ -125,8 +125,12 @@ def _foreign_keys(conn, _roots):
)
def _prompt_hash(text):
return generate_prompt_hash(text) if isinstance(text, str) else None
def _canonical_hashes(conn, _roots):
register_prompt_functions(conn) # pm_prompt_hash, shared with history (#460)
conn.create_function("pm_prompt_hash", 1, _prompt_hash, deterministic=True)
return _counted(
f"Prompt {row[0]}: the stored hash does not match its text"
for row in conn.execute(
+28 -66
View File
@@ -15,11 +15,6 @@ from .execution_history import HistoryEpochChanged
from .execution_outputs import _capture, _REFERENCE_COLUMNS
from .library import _ELIGIBLE
try:
from ..utils.hashing import prompt_key, stored_prompt_text
except ImportError:
from utils.hashing import prompt_key, stored_prompt_text
RunKey = tuple[int, str, int, str]
@@ -105,43 +100,14 @@ _ORDER = (
)
# Stale-hint rows a newest-pair lookup inspects before it reports none.
_PAIR_RUN_CANDIDATES = 16
# The recorded texts of one scope, as a JSON array parameter (_scope_text_ids).
_IN_SCOPE = "IN (SELECT value FROM json_each(?))"
_MATCHING = (
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
"JOIN execution_run_contexts rc ON rc.context_id=c.context_id "
f"WHERE s.raw_text_id {_IN_SCOPE}"
"WHERE s.raw_text_id=?"
)
def _scope_text_ids(conn: sqlite3.Connection, key: str) -> str:
"""Every recorded raw text whose prompt key is ``key``, as a JSON array.
A run keeps its exact raw text, while the Library saves and counts it as
the prompt with its key (outer whitespace #438, case #460). The values are
compared, never only their digests; ``pm_prompt_key`` is Python's key.
"""
return json.dumps(
[
row[0]
for row in conn.execute(
# Only snapshot raw texts: effective and affix texts are skipped
"SELECT t.text_id FROM (SELECT DISTINCT raw_text_id AS text_id "
"FROM execution_prompt_snapshots) s CROSS JOIN "
"execution_context_texts t WHERE t.text_id=s.text_id AND "
"pm_prompt_key(t.text_value)=? COLLATE BINARY",
(key,),
)
]
)
def _scope_text_id(raw: str | None) -> str | None:
"""The scope digest of a raw text: that of its prompt key."""
return None if raw is None else content_digest("text", (prompt_key(raw),))
def _limit(limit: int) -> None:
if type(limit) is not int or not 1 <= limit <= 100:
raise ValueError("History limit must be an integer from 1 to 100")
@@ -289,15 +255,8 @@ class HistoryReadOperations:
if saved is None:
return None
text = saved["text"]
raw_id = _scope_text_id(text)
# A position from before #460 names the scope by the stripped text
# alone; it is the same scope, so a page open across the upgrade resumes.
legacy_id = (
None
if text is None
else content_digest("text", (stored_prompt_text(text),))
)
if after is not None and after.raw_text_id not in (raw_id, legacy_id):
raw_id = None if text is None else content_digest("text", (text,))
if after is not None and after.raw_text_id != raw_id:
raise ValueError("History position text scope changed")
group = {
"positive_id": positive_id,
@@ -314,11 +273,18 @@ class HistoryReadOperations:
next_position=None,
resume_position=None,
)
if raw_id is None:
if (
raw_id is None
or conn.execute(
"SELECT 1 FROM execution_context_texts WHERE text_id=? "
"AND text_value=? COLLATE BINARY",
(raw_id, text),
).fetchone()
is None
):
return result
scope = _scope_text_ids(conn, prompt_key(text))
where = f"r.run_id IN ({_MATCHING})"
params = [scope]
params = [raw_id]
if after is not None:
where += f" AND {_KEY}<=(?,?,?,?)"
params.extend(after.upper)
@@ -372,9 +338,9 @@ class HistoryReadOperations:
"LEFT JOIN execution_prompt_snapshots n "
"ON n.snapshot_id=c.negative_snapshot_id "
"LEFT JOIN execution_context_texts t ON t.text_id=n.raw_text_id "
f"WHERE rc.run_id IN ({placeholders}) AND p.raw_text_id {_IN_SCOPE} "
f"WHERE rc.run_id IN ({placeholders}) AND p.raw_text_id=? "
"ORDER BY rc.anchor_id COLLATE BINARY,rc.run_id COLLATE BINARY",
(*by_run, scope),
(*by_run, raw_id),
)
summaries = {}
evidence_by_context = {}
@@ -472,7 +438,6 @@ class HistoryReadOperations:
if saved["text"] is None:
return {"run": None}
epoch = self.get_execution_epoch()
key = prompt_key(saved["text"])
candidates = conn.execute(
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
"c.positive_snapshot_id,c.negative_snapshot_id "
@@ -480,11 +445,10 @@ class HistoryReadOperations:
"ON rc.run_id=r.run_id JOIN execution_contexts c "
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
"ON s.snapshot_id=c.positive_snapshot_id "
f"WHERE s.raw_text_id {_IN_SCOPE} AND rc.pair_id=? "
"AND rc.reference_epoch=? "
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
(
_scope_text_ids(conn, key),
content_digest("text", (saved["text"],)),
pair_id,
epoch,
_PAIR_RUN_CANDIDATES,
@@ -493,11 +457,7 @@ class HistoryReadOperations:
for row in candidates:
positive = _values(conn, row["positive_snapshot_id"])
negative = _values(conn, row["negative_snapshot_id"])
if (
positive is None
or positive["raw"] is None
or prompt_key(positive["raw"]) != key
):
if positive is None or positive["raw"] != saved["text"]:
continue
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
if hints[2] == pair_id:
@@ -517,9 +477,11 @@ class HistoryReadOperations:
snapshots = {
row["anchor_id"]: row
for row in conn.execute(
"SELECT rc.anchor_id,c.positive_snapshot_id,c.negative_snapshot_id "
"SELECT rc.anchor_id,c.positive_snapshot_id,c.negative_snapshot_id,"
"p.raw_text_id AS positive_raw_text_id "
"FROM execution_run_contexts rc JOIN execution_contexts c "
"ON c.context_id=rc.context_id WHERE rc.run_id=?",
"ON c.context_id=rc.context_id LEFT JOIN execution_prompt_snapshots p "
"ON p.snapshot_id=c.positive_snapshot_id WHERE rc.run_id=?",
(run_id,),
)
}
@@ -529,12 +491,12 @@ class HistoryReadOperations:
context.update(
{
key: row[key]
for key in ("positive_snapshot_id", "negative_snapshot_id")
},
# The history scope this context belongs to (#438)
positive_raw_text_id=_scope_text_id(
(context["positive"] or {}).get("raw")
),
for key in (
"positive_snapshot_id",
"negative_snapshot_id",
"positive_raw_text_id",
)
}
)
pair = context["pair_id"]
context["navigation"] = {
-2
View File
@@ -221,7 +221,6 @@ class ImageCategoryOperations:
sources. Returns ``{updated, unchanged}``; a file that already had
exactly this value is ``unchanged`` and keeps its ``updated_at``.
"""
from .image_removal_schema import clear_marks_of_keys
from .models import utc_now_iso # models imports this module's schema
keys = list(dict.fromkeys(tuple(key) for key in file_keys))
@@ -235,7 +234,6 @@ class ImageCategoryOperations:
continue
conn.execute(UPSERT_SQL, (*key, category, now))
counts["updated"] += 1
clear_marks_of_keys(conn, keys) # #480: a categorized file is listed
return counts
def clear_image_categories(self, file_keys) -> int:
-2
View File
@@ -402,7 +402,6 @@ class ImageCollectionOperations:
is created only when at least one item is inserted. Returns
``{collection, created, added, already}``.
"""
from .image_removal_schema import clear_marks_of_keys
from .models import utc_now_iso # models imports this module's schema
if (collection_id is None) == (name is None):
@@ -439,7 +438,6 @@ class ImageCollectionOperations:
).rowcount
for root, path in pairs
)
clear_marks_of_keys(conn, pairs) # #480: a collected file is listed
return {
"collection": collection,
"created": created,
-3
View File
@@ -104,14 +104,11 @@ class ImageFavoriteOperations:
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
from .models import utc_now_iso # models imports this module's schema
from .image_removal_schema import clear_marks_of_keys
with self.model.get_connection() as conn:
conn.execute(
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
(root_key, relative_path, utc_now_iso()),
)
clear_marks_of_keys(conn, [(root_key, relative_path)]) # #480
return True
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
-322
View File
@@ -1,322 +0,0 @@
"""Remove a file's records from the library, and its removal mark (#480).
A *removal* describes one physical file the way the API resolved it from the
walked listing (``py/api/image_delete.py``):
- ``spellings``: every path string records may name it by
(``duplicate_spellings.Spellings.of``);
- ``locator``: its private canonical locator;
- ``favorites``: ``(root key, relative key)`` under every listed root holding
it, the keys of its favorite and its collection memberships;
- ``category``: its own category's file key, or None.
``remove_image_records`` deletes, in one ``BEGIN IMMEDIATE`` transaction:
- its image records (``generated_images``) under every spelling, plus legacy
rows matched by ``image_path``; their saved-pair links go by cascade and the
workflow documents they held are released;
- the image tags and auto-tag marks of its byte records. The annotation file
and byte rows go too, unless a Needs review item still cites them
(``media_review_items.byte_id`` is ``ON DELETE RESTRICT``): a decided or
revised item keeps its decision;
- its pending, undecided Needs review items (settled as duplicate removal
settles them);
- its own category, favorite and collection memberships.
With ``mark`` it also writes the file's removal mark (library-only removal):
the file stays on disk, hidden until its bytes change. Prompts, recorded runs
and the media file itself are never touched here.
"""
import os
from .annotation_schema import BYTES_TABLE, FILES_TABLE
from .annotations import _BUMP_INDEX
from .autotag_schema import AUTOTAG_TABLE
from .duplicate_copies import (
DELETE_FAVORITE_SQL,
DELETE_LINK_SQL,
DELETE_MEMBERSHIPS_SQL,
_file_link_rows,
)
from .image_categories import DELETE_SQL as DELETE_CATEGORY_SQL
from .image_removal_schema import (
META_TABLE,
TABLE,
clear_marks,
has_marks_table,
mark_of,
write_mark as _write_mark,
)
from .library import display_title
from .models import utc_now_iso
from .review import _BUMP_REVIEW_INDEX, _PENDING_OF_LOCATOR, _SETTLE_ITEM
from .review_changes import notify_review_change
from .workflow_documents import documents_of_rows, release_documents
BYTES_OF_LOCATOR_SQL = (
f"SELECT b.id FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b " # nosec B608
"ON b.file_id=f.id WHERE f.locator=?"
)
TAG_COUNT_SQL = "SELECT COUNT(*) FROM media_image_tags WHERE byte_id=?"
AUTOTAG_COUNT_SQL = (
f"SELECT COUNT(*) FROM {AUTOTAG_TABLE} WHERE byte_id=?" # nosec B608
)
CLEAR_TAGS_SQL = "DELETE FROM media_image_tags WHERE byte_id=?"
CLEAR_AUTOTAG_SQL = f"DELETE FROM {AUTOTAG_TABLE} WHERE byte_id=?" # nosec B608
BUMP_TAG_REVISION_SQL = (
f"UPDATE {BYTES_TABLE} SET tag_revision=tag_revision+1 WHERE id=?" # nosec B608
)
REVIEW_REFS_SQL = (
f"SELECT COUNT(*) FROM {FILES_TABLE} f " # nosec B608
f"JOIN {BYTES_TABLE} b ON b.file_id=f.id "
"JOIN media_review_items i ON i.byte_id=b.id WHERE f.locator=?"
)
DELETE_FILE_SQL = f"DELETE FROM {FILES_TABLE} WHERE locator=?" # nosec B608
FILE_EXISTS_SQL = f"SELECT 1 FROM {FILES_TABLE} WHERE locator=?" # nosec B608
FAVORITE_EXISTS_SQL = (
"SELECT COUNT(*) FROM image_favorites WHERE root_key=? AND relative_path=?"
)
MEMBERSHIP_COUNT_SQL = (
"SELECT COUNT(*) FROM image_collection_items WHERE root_key=? AND relative_path=?"
)
CATEGORY_EXISTS_SQL = (
"SELECT COUNT(*) FROM image_categories WHERE root_key=? AND relative_path=?"
)
PAIR_LINK_COUNT_SQL = "SELECT COUNT(*) FROM image_prompt_pairs WHERE image_id=?"
PROMPT_LINKS_SQL = "SELECT id FROM generated_images WHERE prompt_id=?"
PROMPT_TITLE_SQL = (
"SELECT p.text, s.title FROM prompts p "
"LEFT JOIN prompt_library_state s ON s.prompt_id=p.id WHERE p.id=?"
)
MARKS_SQL = f"SELECT locator, size, mtime_ns FROM {TABLE}" # nosec B608
REVISION_SQL = f"SELECT revision FROM {META_TABLE} WHERE singleton_id=1" # nosec B608
MAX_ORPHAN_TITLES = 3
RECORD_KINDS = (
"prompt_links",
"pair_links",
"image_tags",
"auto_tagged",
"categories",
"collections",
"favorites",
"review_items",
)
def _link_ids(conn, removal):
"""``{image id: prompt id}`` of the records naming the file, any spelling."""
found = {}
for spelled in dict.fromkeys(removal["spellings"]):
for image_id, prompt_id in _file_link_rows(conn, spelled):
found[image_id] = prompt_id
return found
def _byte_ids(conn, locator):
return [row[0] for row in conn.execute(BYTES_OF_LOCATOR_SQL, (locator,))]
def file_locator(path) -> str:
"""The locator of an absolute path (``normcase(realpath)``)."""
return os.path.normcase(os.path.realpath(str(path)))
def removed_from_library(conn, path) -> bool:
"""Whether *path* is hidden by a removal mark whose bytes still match.
A mark whose file changed (other size or ``mtime_ns``) or is gone is
stale: it is deleted on *conn* (the caller's write transaction), and the
file is not hidden.
"""
locator = file_locator(path)
mark = mark_of(conn, locator)
if mark is None:
return False
try:
info = os.lstat(path)
except OSError:
clear_marks(conn, [locator])
return False
if (info.st_size, info.st_mtime_ns) == tuple(mark):
return True
clear_marks(conn, [locator])
return False
def mark_hides(marks: dict, locator: str, path) -> bool:
"""Whether a removal mark in *marks* hides the file at *path* (blocking).
*marks* is ``{locator: (size, mtime_ns)}``. The mark of *locator* hides
the file while ``os.lstat(path)`` still has the size and ``mtime_ns`` it
was removed with; a file that cannot be looked at is not hidden.
"""
mark = marks.get(locator)
if mark is None:
return False
try:
info = os.lstat(path)
except OSError:
return False
return (info.st_size, info.st_mtime_ns) == tuple(mark)
def write_mark(conn, locator, size, mtime_ns):
_write_mark(conn, locator, size, mtime_ns, utc_now_iso())
class ImageRemovalOperations:
"""PromptDatabase mixin: remove a file's records; removal marks."""
def count_removal_records(self, removals) -> dict:
"""Counts of what removing *removals* deletes, in one read snapshot.
``{records: {kind: n}, orphaned_prompts: {count, titles}}``: prompts
whose every image record is among these files lose their last image
(they stay in the Library).
"""
from .maintenance import _read_snapshot
def read(conn):
totals = dict.fromkeys(RECORD_KINDS, 0)
links = {}
for removal in removals:
found = _link_ids(conn, removal)
links.update(found)
for image_id in found:
totals["pair_links"] += conn.execute(
PAIR_LINK_COUNT_SQL, (image_id,)
).fetchone()[0]
tagged = autotagged = False
for byte_id in _byte_ids(conn, removal["locator"]):
tagged |= bool(
conn.execute(TAG_COUNT_SQL, (byte_id,)).fetchone()[0]
)
autotagged |= bool(
conn.execute(AUTOTAG_COUNT_SQL, (byte_id,)).fetchone()[0]
)
totals["image_tags"] += int(tagged)
totals["auto_tagged"] += int(autotagged)
totals["review_items"] += len(
conn.execute(_PENDING_OF_LOCATOR, (removal["locator"],)).fetchall()
)
totals["favorites"] += int(
any(
conn.execute(FAVORITE_EXISTS_SQL, key).fetchone()[0]
for key in removal["favorites"]
)
)
totals["collections"] += sum(
conn.execute(MEMBERSHIP_COUNT_SQL, key).fetchone()[0]
for key in removal["favorites"]
)
if removal.get("category"):
totals["categories"] += conn.execute(
CATEGORY_EXISTS_SQL, removal["category"]
).fetchone()[0]
totals["prompt_links"] = len(links)
return {
"records": totals,
"orphaned_prompts": self._orphaned_prompts(conn, links),
}
return _read_snapshot(self.model.db_path, read)
@staticmethod
def _orphaned_prompts(conn, links):
removed = set(links)
orphans = []
for prompt_id in dict.fromkeys(links.values()):
remaining = [
row[0]
for row in conn.execute(PROMPT_LINKS_SQL, (prompt_id,))
if row[0] not in removed
]
if not remaining:
orphans.append(prompt_id)
titles = []
for prompt_id in orphans[:MAX_ORPHAN_TITLES]:
row = conn.execute(PROMPT_TITLE_SQL, (prompt_id,)).fetchone()
if row is not None:
titles.append(display_title((row[1],), row[0]) or "")
return {"count": len(orphans), "titles": titles}
def remove_image_records(self, removal, *, mark=None) -> dict:
"""Delete every record of one file (one transaction); see the module.
``mark``: ``(size, mtime_ns)`` to write the file's removal mark, or
None (the file was moved to the trash). Returns the counts deleted.
"""
counts = dict.fromkeys(RECORD_KINDS, 0)
locator = removal["locator"]
with self._pair_transaction() as conn:
links = _link_ids(conn, removal)
if links:
documents = documents_of_rows(conn, "id", list(links))
for image_id in links:
counts["pair_links"] += conn.execute(
PAIR_LINK_COUNT_SQL, (image_id,)
).fetchone()[0]
conn.execute(DELETE_LINK_SQL, (image_id,))
release_documents(conn, documents)
counts["prompt_links"] = len(links)
counts.update(self._remove_annotations(conn, locator))
for key in removal["favorites"]:
counts["favorites"] += conn.execute(DELETE_FAVORITE_SQL, key).rowcount
counts["collections"] += conn.execute(
DELETE_MEMBERSHIPS_SQL, key
).rowcount
if removal.get("category"):
counts["categories"] += conn.execute(
DELETE_CATEGORY_SQL, tuple(removal["category"])
).rowcount
if mark is not None:
write_mark(conn, locator, *mark)
if counts["review_items"]:
notify_review_change(self.model.db_path)
return counts
def _remove_annotations(self, conn, locator) -> dict:
"""Tags, auto-tag marks, pending review items and, when free, the rows."""
counts = {"image_tags": 0, "auto_tagged": 0, "review_items": 0}
bytes_ = _byte_ids(conn, locator)
for byte_id in bytes_:
tags = conn.execute(CLEAR_TAGS_SQL, (byte_id,)).rowcount
autotags = conn.execute(CLEAR_AUTOTAG_SQL, (byte_id,)).rowcount
if tags:
conn.execute(BUMP_TAG_REVISION_SQL, (byte_id,))
counts["image_tags"] |= bool(tags)
counts["auto_tagged"] |= bool(autotags)
for (item_id,) in conn.execute(_PENDING_OF_LOCATOR, (locator,)).fetchall():
counts["review_items"] += conn.execute(_SETTLE_ITEM, (item_id,)).rowcount
if counts["review_items"]:
conn.execute(_BUMP_REVIEW_INDEX)
if conn.execute(FILE_EXISTS_SQL, (locator,)).fetchone():
if not conn.execute(REVIEW_REFS_SQL, (locator,)).fetchone()[0]:
conn.execute(DELETE_FILE_SQL, (locator,))
conn.execute(_BUMP_INDEX)
counts["image_tags"] = int(counts["image_tags"])
counts["auto_tagged"] = int(counts["auto_tagged"])
return counts
def removal_marks(self) -> dict:
"""``{locator: (size, mtime_ns)}`` of every removal mark (one read)."""
conn = self.model.get_connection()
if not has_marks_table(conn):
return {}
return {row[0]: (row[1], row[2]) for row in conn.execute(MARKS_SQL)}
def removal_revision(self) -> int:
conn = self.model.get_connection()
if not has_marks_table(conn):
return 0
row = conn.execute(REVISION_SQL).fetchone()
return row[0] if row else 0
def clear_removal_marks(self, locators) -> int:
"""Show these files again (their marks go); the count removed."""
locators = list(locators)
if not locators:
return 0
with self._pair_transaction() as conn:
return clear_marks(conn, locators)
-208
View File
@@ -1,208 +0,0 @@
"""Removal marks (#480): files removed from the library that stay on disk.
``image_removals`` holds one row per hidden file: its private canonical
locator (the ``media_annotation_files.locator`` form) and the ``size`` and
``mtime_ns`` it had when it was removed. A file at that locator with other
bytes is a new file and shows again. ``image_removals_meta`` is one row, the
marks revision, bumped in the same transaction as every mark change so the
workspace listing caches can tell marks changed.
The group installs like ``image_favorites``: optional in old backups,
required once a database is current. Older builds ignore it.
"""
import os
import re
import sqlite3
from .annotation_schema import MAX_LOCATOR_LENGTH
TABLE = "image_removals"
META_TABLE = "image_removals_meta"
TABLES = (TABLE, META_TABLE)
_TABLE_DDL = {
TABLE: (
f"CREATE TABLE {TABLE} (locator TEXT PRIMARY KEY, "
"size INTEGER NOT NULL CHECK(size>=0), mtime_ns INTEGER NOT NULL, "
"removed_at TEXT NOT NULL) WITHOUT ROWID"
),
META_TABLE: (
f"CREATE TABLE {META_TABLE} (singleton_id INTEGER PRIMARY KEY "
"CHECK(singleton_id=1), revision INTEGER NOT NULL DEFAULT 0 "
"CHECK(revision>=0))"
),
}
_SEED_META = (
f"INSERT OR IGNORE INTO {META_TABLE} (singleton_id, revision) VALUES (1, 0)"
)
_COLUMNS = {
TABLE: (
("locator", "TEXT", 1, 1),
("size", "INTEGER", 1, 0),
("mtime_ns", "INTEGER", 1, 0),
("removed_at", "TEXT", 1, 0),
),
META_TABLE: (
("singleton_id", "INTEGER", 0, 1),
("revision", "INTEGER", 1, 0),
),
}
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql or "").lower()
def _structure_error(conn: sqlite3.Connection, allow_missing: bool) -> str | None:
rows = conn.execute(
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name IN (?,?) "
"OR tbl_name IN (?,?)",
TABLES + TABLES,
).fetchall()
if not rows:
return None if allow_missing else "Missing image removal marks schema"
tables = {name: sql for kind, name, _tbl, sql in rows if kind == "table"}
others = {(kind, name) for kind, name, _tbl, _sql in rows if kind != "table"}
if set(tables) != set(TABLES) or others - {("index", _AUTOINDEX)}:
return "Image removal marks name is reserved by an unsupported schema object"
for table in TABLES:
if _compact(tables[table]) != _compact(_TABLE_DDL[table]):
return "Image removal marks have an invalid table definition"
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_info({table})")
)
if columns != _COLUMNS[table]:
return "Image removal marks have invalid columns"
return None
def _values_error(conn: sqlite3.Connection) -> str | None:
limit = MAX_LOCATOR_LENGTH * 4
if conn.execute(
f"SELECT 1 FROM {TABLE} WHERE typeof(locator) != 'text' " # nosec B608
f"OR length(CAST(locator AS BLOB)) > {limit} OR locator = '' "
"OR typeof(size) != 'integer' OR typeof(mtime_ns) != 'integer' "
"OR typeof(removed_at) != 'text' OR length(CAST(removed_at AS BLOB)) > 64 "
"LIMIT 1"
).fetchone():
return "Image removal marks contain invalid values"
meta = conn.execute(
f"SELECT singleton_id, typeof(revision) FROM {META_TABLE}" # nosec B608
).fetchall()
if meta != [(1, "integer")]:
return "Image removal marks have an invalid revision"
return None
def image_removals_schema_error(
conn: sqlite3.Connection, *, allow_missing: bool = True
) -> str | None:
"""Reject unsupported schemas and invalid persisted values (reads only)."""
error = _structure_error(conn, allow_missing)
if error:
return error
exists = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
).fetchone()
return _values_error(conn) if exists else None
def migrate_image_removals(conn: sqlite3.Connection) -> None:
"""Install both tables atomically; validate before and after."""
conn.execute("SAVEPOINT image_removals_schema")
try:
error = image_removals_schema_error(conn)
if error:
raise sqlite3.DatabaseError(error)
for sql in _TABLE_DDL.values():
conn.execute(sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
conn.execute(_SEED_META)
error = image_removals_schema_error(conn, allow_missing=False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE image_removals_schema")
except BaseException:
conn.execute("ROLLBACK TO image_removals_schema")
conn.execute("RELEASE image_removals_schema")
raise
_UNMARK_SQL = f"DELETE FROM {TABLE} WHERE locator=?" # nosec B608
_MARK_SQL = (
f"INSERT OR REPLACE INTO {TABLE} " # nosec B608
"(locator, size, mtime_ns, removed_at) VALUES (?, ?, ?, ?)"
)
_MARK_OF_SQL = f"SELECT size, mtime_ns FROM {TABLE} WHERE locator=?" # nosec B608
BUMP_MARKS_SQL = (
f"UPDATE {META_TABLE} SET revision=revision+1 WHERE singleton_id=1" # nosec B608
)
# Bumped whenever this process writes or clears a mark (even in a transaction
# that later rolls back): readers that cache the marks reload when it moves.
_generation = 0
def generation() -> int:
"""The marks generation of this process (see ``_generation``)."""
return _generation
def _bump() -> None:
global _generation
_generation += 1
def has_marks_table(conn: sqlite3.Connection) -> bool:
return (
conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
).fetchone()
is not None
)
def locator_of_key(root_key: str, relative_key: str) -> str:
"""The locator of a favorite-style file key (root key + relative key)."""
return os.path.normcase(os.path.join(root_key, *relative_key.split("/")))
def mark_of(conn: sqlite3.Connection, locator: str):
"""``(size, mtime_ns)`` of the file's removal mark, or None."""
if not has_marks_table(conn):
return None
row = conn.execute(_MARK_OF_SQL, (locator,)).fetchone()
return (row[0], row[1]) if row else None
def write_mark(
conn: sqlite3.Connection, locator: str, size: int, mtime_ns: int, now: str
):
conn.execute(_MARK_SQL, (locator, int(size), int(mtime_ns), now))
conn.execute(BUMP_MARKS_SQL)
_bump()
def clear_marks(conn: sqlite3.Connection, locators) -> int:
"""Delete the marks of *locators* on *conn* (no commit); the count removed.
Every record write for a file calls this (#480): a file with records is
in the library, so it is never hidden.
"""
locators = list(dict.fromkeys(locators))
if not locators or not has_marks_table(conn):
return 0
removed = 0
for locator in locators:
removed += conn.execute(_UNMARK_SQL, (locator,)).rowcount
if removed:
conn.execute(BUMP_MARKS_SQL)
_bump()
return removed
def clear_marks_of_keys(conn: sqlite3.Connection, file_keys) -> int:
"""``clear_marks`` for favorite-style ``(root key, relative key)`` pairs."""
return clear_marks(conn, [locator_of_key(*key) for key in file_keys])
+28 -125
View File
@@ -5,9 +5,7 @@ import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from .library_folders import folder_condition, list_library_folders
from .library_tokens import library_edit_token
from .prompt_archive import ARCHIVED, SHELVED
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
OR EXISTS(SELECT 1 FROM generated_images eg WHERE eg.prompt_id=p.id)
@@ -107,12 +105,6 @@ ARCHIVE_SCOPES = {
}
# Shelves (#443): the parents on the shelf, or the archived ones.
PARENT_SCOPES = {"active": SHELVED, "archived": ARCHIVED}
# A positive pair owner that is on the shelf, as a condition on alias pp.
SHELVED_PAIR = SHELVED.replace("p.id", "pp.positive_prompt_id")
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@@ -129,54 +121,7 @@ def _archive(value: str) -> str:
return ARCHIVE_SCOPES[value]
# How the Tags filter matches (#444): every tag (AND), any of them (OR), or
# parents without any tag.
TAG_MODES = ("all", "any", "untagged")
# A parent that no tag's Browse returns (#444 "Untagged prompts"): no tag of its
# own and no active pair carrying one. The vocabulary's untagged count uses it too.
UNTAGGED = (
"NOT EXISTS(SELECT 1 FROM prompt_tags ut WHERE ut.prompt_id=p.id) "
"AND NOT EXISTS(SELECT 1 FROM prompt_pairs up JOIN prompt_pair_tags upt "
"ON upt.pair_id=up.id WHERE up.positive_prompt_id=p.id AND up.archived=0)"
)
def _tag_mode(tag_mode: object, tags: list[str]) -> str:
if not isinstance(tag_mode, str) or tag_mode not in TAG_MODES:
raise ValueError("tag_mode must be one of " + ", ".join(TAG_MODES))
if tag_mode == "untagged" and tags:
raise ValueError("Untagged prompts cannot also filter by tags")
return tag_mode
def _tag_conditions(tags: list[str], any_tag: bool) -> tuple[list, list, list]:
"""Parent-arm and pair-arm tag conditions and the tags' parameters."""
parent_exists = (
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name{test})"
)
pair_exists = (
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name{test})"
)
if any_tag and tags:
# One condition per arm: the card, or one active pair, carries any tag.
test = " IN (" + ",".join("?" for _ in tags) + ")"
return [parent_exists.format(test=test)], [pair_exists.format(test=test)], tags
return (
[parent_exists.format(test="=?") for _ in tags],
[pair_exists.format(test="=?") for _ in tags],
tags,
)
def _filter(
text: str,
category: str | None,
tags: list[str],
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
) -> tuple[str, list]:
def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]:
if not isinstance(text, str) or len(text) > 500:
raise ValueError("Search text must be a string of at most 500 characters")
if category is not None and (not isinstance(category, str) or len(category) > 200):
@@ -187,8 +132,6 @@ def _filter(
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
):
raise ValueError("Invalid library tags")
mode = _tag_mode(tag_mode, tags)
untagged = mode == "untagged"
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
if text:
parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')")
@@ -203,11 +146,17 @@ def _filter(
pair.append("pp.category=?")
parent_params.append(category)
pair_params.append(category)
parent_tags, pair_tags, tag_params = _tag_conditions(tags, mode == "any")
parent.extend(parent_tags)
pair.extend(pair_tags)
parent_params.extend(tag_params)
pair_params.extend(tag_params)
for tag in tags:
parent.append(
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name=?)"
)
pair.append(
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name=?)"
)
parent_params.append(tag)
pair_params.append(tag)
condition = (
"("
+ (" AND ".join(parent) or "1=1")
@@ -219,17 +168,7 @@ def _filter(
+ " AND ".join(pair)
+ ")"
)
if untagged:
# A whole-card condition: inside the parent-or-pair OR an untagged pair
# would let a tagged parent through.
condition = f"({condition}) AND {UNTAGGED}"
params = [*parent_params, *pair_params]
if folder_prefixes:
# A whole-card condition around both arms: images link to the parent.
folder, folder_params = folder_condition(folder_prefixes)
condition = f"({condition}) AND {folder}"
params.extend(folder_params)
return f"({_ELIGIBLE}) AND ({condition})", params
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
class LibraryOperations:
@@ -277,8 +216,6 @@ class LibraryOperations:
rows = conn.execute(
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
"p.run_count,p.last_used_at,"
+ ARCHIVED
+ " AS archived,"
+ (f"{sort_expression} AS _sort," if sort_expression else "")
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
"AND v.archived=0) AS variant_count,"
@@ -302,7 +239,6 @@ class LibraryOperations:
# Usage is shown, never compared: the token hashes owner fields only.
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
row["last_used_at"] = utc_iso(row["last_used_at"])
row["archived"] = bool(row["archived"])
row["edit_token"] = library_edit_token(epoch, row)
return result
@@ -315,21 +251,14 @@ class LibraryOperations:
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
shelf: str = "active",
) -> dict:
_limit(limit)
if sort not in _ORDERS:
raise ValueError("Unknown library sort order")
if not isinstance(shelf, str) or shelf not in PARENT_SCOPES:
raise ValueError("shelf must be active or archived")
where, params = _filter(
text, category, [] if tags is None else tags, tag_mode, folder_prefixes
)
where, params = _filter(text, category, [] if tags is None else tags)
if after is not None:
if (
not isinstance(after, dict)
@@ -341,18 +270,15 @@ class LibraryOperations:
ceiling = after["ceiling"]
where += " AND p.id<=?"
params.append(ceiling)
# One SELECT counts the filtered parents of this scope, their active
# variants, and the filtered archived parents (Show archived, #443).
scope = PARENT_SCOPES[shelf]
total, variant_total, archived_total = conn.execute(
f"SELECT COALESCE(SUM({scope}),0),"
f"COALESCE(SUM(CASE WHEN {scope} THEN (SELECT COUNT(*) "
"FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
"AND v.archived=0) ELSE 0 END),0),"
f"COALESCE(SUM({ARCHIVED}),0) FROM " + _FROM + " WHERE " + where,
# One SELECT counts the filtered parents and their active variants.
total, variant_total = conn.execute(
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
+ _FROM
+ " WHERE "
+ where,
params,
).fetchone()
where += f" AND {scope}"
expression, direction = _ORDERS[sort]
if after is not None:
operator = ">" if direction == "ASC" else "<"
@@ -385,9 +311,6 @@ class LibraryOperations:
"items": rows,
"total": total,
"variant_total": variant_total,
"archived_total": archived_total,
# Echoed so a client can tell this read applied its tag match.
"tag_mode": tag_mode,
"has_more": has_more,
"next_key": next_key,
}
@@ -398,12 +321,9 @@ class LibraryOperations:
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
shelf: str = "active",
) -> dict:
with self.library_snapshot(include_ceiling=True) as (conn, epoch, ceiling):
return self._list_library(
@@ -413,12 +333,9 @@ class LibraryOperations:
text=text,
category=category,
tags=tags,
tag_mode=tag_mode,
folder_prefixes=folder_prefixes,
sort=sort,
limit=limit,
after=after,
shelf=shelf,
)
def _is_library_positive(self, conn: sqlite3.Connection, positive_id: int) -> bool:
@@ -546,13 +463,9 @@ class LibraryOperations:
"categories": [row["category"] for row in rows],
}
def get_library_options(self, folder_roots=None) -> dict:
"""Categories; with *folder_roots* (root spellings) also ``folders``."""
def get_library_options(self) -> dict:
with self.library_snapshot() as (conn, epoch, _ceiling):
result = self._get_library_options(conn, epoch)
if folder_roots is not None:
result["folders"] = list_library_folders(conn, folder_roots, _ELIGIBLE)
return result
return self._get_library_options(conn, epoch)
def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
"""Read at most two links per parent in one bounded page query."""
@@ -663,9 +576,7 @@ class LibraryOperations:
owners = {}
order = []
for row in conn.execute(
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,"
+ ARCHIVED
+ " AS archived FROM "
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes FROM "
+ _FROM
+ " WHERE "
+ _ELIGIBLE
@@ -678,7 +589,6 @@ class LibraryOperations:
"tags": [],
"rating": row["rating"],
"notes": row["notes"],
"archived": bool(row["archived"]),
"variants": [],
}
order.append(row["id"])
@@ -723,9 +633,8 @@ class LibraryOperations:
"""System › Overview counts from one snapshot, writing nothing.
``positive_prompts`` and ``negative_variants`` are the unfiltered
Library's ``total`` and ``variant_total``: eligible parents on the
shelf (not archived, #443) and their active pairs (every pair's
positive is eligible). ``images`` counts indexed
Library's ``total`` and ``variant_total``: eligible parents and active
pairs (every pair's positive is eligible). ``images`` counts indexed
``generated_images`` rows, not files on disk. An image has no rating
of its own (it shows its linked prompt's), so ``rated_images`` counts
the indexed images whose linked prompt is rated. ``review_pending`` is
@@ -733,14 +642,8 @@ class LibraryOperations:
"""
with self.library_snapshot() as (conn, _epoch, _ceiling):
row = conn.execute(
"SELECT (SELECT COUNT(*) FROM prompts p WHERE "
+ _ELIGIBLE
+ " AND "
+ SHELVED
+ "),"
"(SELECT COUNT(*) FROM prompt_pairs pp WHERE pp.archived=0 AND "
+ SHELVED_PAIR
+ "),"
"SELECT (SELECT COUNT(*) FROM prompts p WHERE " + _ELIGIBLE + "),"
"(SELECT COUNT(*) FROM prompt_pairs WHERE archived=0),"
"(SELECT COUNT(*) FROM generated_images),"
"(SELECT COUNT(*) FROM generated_images gi JOIN prompts r "
"ON r.id=gi.prompt_id WHERE r.rating IS NOT NULL),"
-437
View File
@@ -1,437 +0,0 @@
"""Delete selected Library prompts; archive the paired ones (#443, D152).
A selected Library parent is classified, in this order, as ``not_found``
(missing, or not a Library parent), ``protected`` (tagged ``__protected__``;
never changed), ``already_archived``, ``archive`` (the positive or negative of
any saved pair, archived pair or not: archiving hides it from the shelf and
removes nothing) or ``delete`` (in no pair: deleted exactly as the legacy
delete does, through ``delete_prompt_rows``). Every command checks the
storage epoch first and runs in one ``BEGIN IMMEDIATE`` transaction.
The write executes what the user reviewed: it is given the reviewed archive
and delete lists, and deletes only reviewed deletes the route backed up
(``candidates``) whose fingerprint is unchanged since that backup.
"""
import hashlib
import json
import re
import sqlite3
from typing import Iterable
from .library import _ELIGIBLE
from .library_commands import _validate_bulk_ids, _validate_epoch
from .models import utc_now_iso
from .prompt_archive import ARCHIVED as _ARCHIVED
from .prompt_archive import TABLE as ARCHIVE
from .workflow_documents import documents_of_rows, release_documents
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
# Ids bound per statement: the pair check binds each id twice, which stays
# under SQLite's historical 999-variable limit (maintenance.py ID_BATCH).
ID_BATCH = 400
_PROTECTED = (
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name='__protected__')"
)
_PAIRED = (
"EXISTS(SELECT 1 FROM prompt_pairs pp "
"WHERE pp.positive_prompt_id=p.id OR pp.negative_prompt_id=p.id)"
)
# #482: used as the negative of another positive's pair (archived or not):
# never deleted, so that positive's variant stays (archive only).
_NEGATIVE_OF_OTHER = (
"EXISTS(SELECT 1 FROM prompt_pairs op "
"WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)"
)
# Classes in the order a prompt is tested; the first that holds wins.
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
def _batches(ids: list) -> Iterable[list]:
for start in range(0, len(ids), ID_BATCH):
yield ids[start : start + ID_BATCH]
def delete_prompt_rows(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
"""Delete prompts and their image link rows; return the prompts deleted.
The legacy delete's statements (``delete_prompt``, ``bulk_delete_prompts``)
in the caller's transaction: image link rows first (``image_prompt_pairs``
cascades, review decisions keep a NULL image), then the prompts (tags and
Library state cascade), then the workflow documents no row references.
Image files are never touched. The caller checks pairs first.
"""
documents = documents_of_rows(conn, "prompt_id", prompt_ids)
deleted = 0
for chunk in _batches(list(prompt_ids)):
marks = ",".join("?" for _ in chunk)
conn.execute(
f"DELETE FROM generated_images WHERE prompt_id IN ({marks})", # nosec
chunk,
)
deleted += conn.execute(
f"DELETE FROM prompts WHERE id IN ({marks})", chunk # nosec B608
).rowcount
release_documents(conn, documents)
return deleted
class LibraryArchiveOperations:
"""PromptDatabase mixin: classify, archive or delete, and restore parents."""
def _classify_library_delete(
self, conn: sqlite3.Connection, prompt_ids: list[int]
) -> dict:
found, deletable = {}, set()
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
for row in conn.execute(
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED}," # nosec B608
f"{_NEGATIVE_OF_OTHER} "
f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
chunk,
):
protected, archived, paired, negative_of_other = row[1:5]
if protected:
found[row[0]] = "protected"
elif archived:
found[row[0]] = "already_archived"
elif paired:
found[row[0]] = "archive"
if not negative_of_other:
deletable.add(row[0])
else:
found[row[0]] = "delete"
classes = {name: [] for name in CLASSES}
for pid in prompt_ids:
classes[found.get(pid, "not_found")].append(pid)
# #482: the archive ids Delete permanently may delete with their pairs.
classes["deletable"] = [pid for pid in classes["archive"] if pid in deletable]
return classes
@staticmethod
def _image_links(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
total = 0
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
total += conn.execute(
"SELECT COUNT(*) FROM generated_images " # nosec B608
f"WHERE prompt_id IN ({marks})",
chunk,
).fetchone()[0]
return total
@staticmethod
def _fingerprints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
"""What a safety backup holds of each prompt a delete would remove.
The prompt row (usage included), its tag ids, its Library title and
its image link rows (count and newest id), as a JSON-safe list.
"""
prints = {}
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
for row in conn.execute(
"SELECT p.id,p.text,p.hash,p.category,p.rating,p.notes," # nosec
"p.updated_at,p.run_count,p.last_used_at,"
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM prompt_tags "
"WHERE prompt_id=p.id ORDER BY tag_id)),"
"(SELECT title FROM prompt_library_state WHERE prompt_id=p.id),"
"(SELECT COUNT(*) FROM generated_images WHERE prompt_id=p.id),"
"(SELECT MAX(id) FROM generated_images WHERE prompt_id=p.id) "
f"FROM prompts p WHERE p.id IN ({marks})",
chunk,
):
prints[row[0]] = [
value if isinstance(value, (int, str, type(None))) else repr(value)
for value in tuple(row)[1:]
]
return prints
@staticmethod
def _distinct_images(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
"""Image rows of ``prompt_ids`` (each image once; #482 deletes them)."""
return LibraryArchiveOperations._image_links(conn, prompt_ids)
@staticmethod
def _pair_prints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
"""#482: each prompt's own saved pairs, as the review saw them.
Per pair (by id): its negative, archived flag, metadata, usage, ordered
tag ids and its image-pair links (count and newest id).
"""
prints = {pid: [] for pid in prompt_ids}
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
for row in conn.execute(
"SELECT pp.positive_prompt_id,pp.id,pp.negative_prompt_id," # nosec
"pp.archived,pp.title,pp.category,pp.rating,pp.notes,pp.run_count,"
"pp.last_used_at,pp.updated_at,"
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM "
"prompt_pair_tags WHERE pair_id=pp.id ORDER BY tag_id)),"
"(SELECT COUNT(*) FROM image_prompt_pairs WHERE pair_id=pp.id),"
"(SELECT MAX(id) FROM image_prompt_pairs WHERE pair_id=pp.id) "
f"FROM prompt_pairs pp WHERE pp.positive_prompt_id IN ({marks}) "
"ORDER BY pp.id",
chunk,
):
prints[row[0]].append(
[
v if isinstance(v, (int, str, type(None))) else repr(v)
for v in tuple(row)[1:]
]
)
return prints
@classmethod
def _review_digests(cls, conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
"""#482: one digest per deletable prompt of its row and its pairs."""
prompts = cls._fingerprints(conn, prompt_ids)
pairs = cls._pair_prints(conn, prompt_ids)
return {
pid: hashlib.sha256(
json.dumps(
[prompts.get(pid), pairs[pid]], separators=(",", ":")
).encode("utf-8")
).hexdigest()
for pid in prompt_ids
}
@staticmethod
def _paired_counts(
conn: sqlite3.Connection, prompt_ids: list[int], selected: list[int]
) -> dict:
"""#482: what Delete permanently would remove with ``prompt_ids``."""
pairs = archived = variants = 0
negatives = set()
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
for archived_flag, negative, positive in conn.execute(
"SELECT archived,negative_prompt_id,positive_prompt_id " # nosec
f"FROM prompt_pairs WHERE positive_prompt_id IN ({marks})",
chunk,
):
pairs += 1
archived += 1 if archived_flag else 0
if negative is not None:
variants += 1
if negative != positive:
negatives.add(negative)
return {
"pairs": pairs,
"archived_pairs": archived,
"variants": variants,
"kept_negatives": len(negatives - set(selected)),
}
@staticmethod
def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]:
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
return list(dict.fromkeys(prompt_ids))
def classify_library_delete(
self, prompt_ids: list[int], *, expected_epoch: str
) -> dict:
"""What a delete of ``prompt_ids`` would do now (reads only).
Returns the id lists per class, in request order, ``image_links``
(the image link rows the ``delete`` class would remove) and
``fingerprints`` of the ``delete`` class (``_fingerprints``).
"""
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
with self.library_snapshot() as (conn, _epoch, _ceiling):
epoch = self._library_epoch(conn, expected_epoch)
classes = self._classify_library_delete(conn, prompt_ids)
deletable = classes["deletable"]
return {
"library_epoch": epoch,
**classes,
"image_links": self._image_links(conn, classes["delete"]),
"fingerprints": self._fingerprints(conn, classes["delete"]),
# #482: Delete permanently, with the deletable prompts' pairs.
"review_digests": self._review_digests(conn, deletable),
"paired_counts": self._paired_counts(conn, deletable, prompt_ids),
"paired_image_links": self._distinct_images(conn, deletable),
}
@staticmethod
def _repair_hash(conn: sqlite3.Connection, prompt_id: int) -> None:
"""Give an archived prompt its canonical hash, so ingestion finds it.
Scans, the monitor and node saves resolve prompts by hash; a stale or
missing one would make them insert a visible copy (Q3). When another
row already holds the canonical hash, lookups find that row and no new
copy is made, so nothing is written.
"""
text, stored = conn.execute(
"SELECT text,hash FROM prompts WHERE id=?", (prompt_id,)
).fetchone()
canonical = generate_prompt_hash(text) if isinstance(text, str) else None
if canonical is None or stored == canonical:
return
if conn.execute(
"SELECT 1 FROM prompts WHERE hash=? AND id!=?", (canonical, prompt_id)
).fetchone():
return
conn.execute("UPDATE prompts SET hash=? WHERE id=?", (canonical, prompt_id))
def archive_or_delete_library_prompts(
self,
archive_ids: list[int],
delete_ids: list[int],
*,
expected_epoch: str,
candidates: dict | None,
delete_paired: dict | None = None,
) -> dict:
"""Archive and delete what the user reviewed, all or nothing.
``archive_ids`` and ``delete_ids`` are the reviewed classes.
``candidates`` maps each reviewed delete the route backed up to its
fingerprint then (None: every reviewed delete, unchecked; tests and
callers without a backup). A reviewed delete is deleted only when it
still classifies ``delete`` and its fingerprint is unchanged; one that
is now paired, and a reviewed archive now unpaired, are archived (the
restorable direction); anything else is kept and counted.
``delete_paired`` (#482) maps reviewed archive ids the user chose to
Delete permanently to their review digest. Each is deleted with its
own saved pairs only while it is still deletable and its digest is
unchanged; otherwise it is archived and counted ``changed``.
"""
_validate_epoch(expected_epoch)
for ids in (archive_ids, delete_ids):
if not isinstance(ids, list) or any(
type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in ids
):
raise ValueError("prompt ids must be lists of positive integers")
reviewed_delete = set(delete_ids)
delete_paired = {} if delete_paired is None else delete_paired
if not isinstance(delete_paired, dict) or any(
type(pid) is not int
or not isinstance(digest, str)
or not _DIGEST.match(digest)
for pid, digest in delete_paired.items()
):
raise ValueError("delete_paired must map prompt ids to review digests")
if not set(delete_paired) <= set(archive_ids):
raise ValueError("A permanent delete must be a reviewed archive id")
if not archive_ids and not delete_ids:
raise ValueError("Nothing to delete or archive")
if reviewed_delete & set(archive_ids):
raise ValueError("A prompt cannot be both archived and deleted")
prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids]))
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
classes = self._classify_library_delete(conn, prompt_ids)
now_prints = self._fingerprints(conn, classes["delete"])
deletes, archives, changed = [], [], 0
paired_now = set(classes["deletable"])
chosen = [pid for pid in classes["archive"] if pid in delete_paired]
digests = self._review_digests(
conn, [pid for pid in chosen if pid in paired_now]
)
paired_deletes = []
for pid in classes["archive"]:
if pid not in delete_paired:
archives.append(pid)
elif digests.get(pid) == delete_paired[pid]:
paired_deletes.append(pid)
else:
archives.append(pid) # changed since the review: restorable
changed += 1
for pid in classes["delete"]:
if pid not in reviewed_delete:
archives.append(pid) # reviewed as archive, now unpaired
changed += 1 if pid in delete_paired else 0
elif candidates is None or candidates.get(pid) == now_prints[pid]:
deletes.append(pid)
else:
changed += 1
changed += len(classes["protected"])
image_links = self._image_links(conn, [*deletes, *paired_deletes])
now = utc_now_iso()
for pid in archives:
self._repair_hash(conn, pid)
conn.executemany(
f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)",
[(pid, now) for pid in archives],
)
# A changed row is the newest edit the restore fingerprint reads.
conn.executemany(
"UPDATE prompts SET updated_at=? WHERE id=?",
[(now, pid) for pid in archives],
)
# #482: the image-pair links (RESTRICT) go first, then the pairs
# (pair tags CASCADE, review decisions SET NULL), then the prompts.
pairs_removed = 0
for chunk in _batches(paired_deletes):
marks = ",".join("?" for _ in chunk)
pairs_of = (
"SELECT id FROM prompt_pairs " # nosec B608
f"WHERE positive_prompt_id IN ({marks})"
)
conn.execute(
f"DELETE FROM image_prompt_pairs WHERE pair_id IN ({pairs_of})",
chunk,
)
pairs_removed += conn.execute(
"DELETE FROM prompt_pairs " # nosec B608
f"WHERE positive_prompt_id IN ({marks})",
chunk,
).rowcount
deleted = delete_prompt_rows(conn, [*deletes, *paired_deletes])
return {
"library_epoch": epoch,
"deleted": deleted,
"archived": len(archives),
"already_archived": len(classes["already_archived"]),
"not_found": len(classes["not_found"]),
"changed": changed,
"image_links": image_links,
"pairs_removed": pairs_removed,
}
def restore_library_prompts(
self, prompt_ids: list[int], *, expected_epoch: str
) -> dict:
"""Put archived prompts back on the shelf, all or nothing.
Every requested id with an archive row is restored, whatever its
protection or Library parent state: restoring only un-hides it.
"""
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
restored, existing = [], set()
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
for pid, archived in conn.execute(
f"SELECT p.id,{_ARCHIVED} FROM prompts p " # nosec B608
f"WHERE p.id IN ({marks})",
chunk,
):
existing.add(pid)
if archived:
restored.append(pid)
now = utc_now_iso()
conn.executemany(
f"DELETE FROM {ARCHIVE} WHERE prompt_id=?", [(pid,) for pid in restored]
)
conn.executemany(
"UPDATE prompts SET updated_at=? WHERE id=?",
[(now, pid) for pid in restored],
)
return {
"library_epoch": epoch,
"restored": len(restored),
"unchanged": len(existing) - len(restored),
"not_found": len(prompt_ids) - len(existing),
}
-142
View File
@@ -21,10 +21,6 @@ _UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
_TOKEN = re.compile(r"[0-9a-f]{64}")
# Parent metadata stored on the prompt row; title lives in prompt_library_state.
_PROMPT_COLUMNS = ("category", "rating", "notes")
# Ids per IN (...) read: well under SQLite's oldest bound-parameter limit (999).
_BULK_CHUNK = 500
# The tags one prompt may carry: what the editor's save accepts (``validate_tags``).
MAX_LIBRARY_TAGS = 20
class LibraryCommandError(Exception):
@@ -89,13 +85,6 @@ def _validate_epoch(expected_epoch: str) -> None:
raise ValueError("expected_epoch must be a canonical UUID")
def _validate_bulk_ids(prompt_ids: list[int]) -> None:
if not isinstance(prompt_ids, list) or not prompt_ids:
raise ValueError("prompt_ids must be a non-empty list")
if any(type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in prompt_ids):
raise ValueError("prompt_ids must be positive integers")
def _validate_guards(positive_id: int, expected_epoch: str, if_match: str) -> None:
if type(positive_id) is not int or not 1 <= positive_id <= 2**63 - 1:
raise ValueError("Invalid positive prompt ID")
@@ -319,134 +308,3 @@ class LibraryCommandOperations:
positive_created=False,
pair_created=pair_created,
)
def bulk_set_library_category(
self, prompt_ids: list[int], category: str, *, expected_epoch: str
) -> dict:
"""Set one category on many Library parents, all or nothing (#437).
Guarded by the storage epoch the page read. Only rows whose category
differs are written (their ``updated_at`` and edit token change) and
registered as Library parents, as the editor's PATCH does; ids
that are missing or are not Library parents count as not found.
"""
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
prompt_ids = list(dict.fromkeys(prompt_ids))
if not isinstance(category, str) or not category.strip():
raise ValueError("Category is required")
validate_category(category)
category = category.strip()
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
current = {}
for start in range(0, len(prompt_ids), _BULK_CHUNK):
chunk = prompt_ids[start : start + _BULK_CHUNK]
marks = ",".join("?" for _ in chunk)
current.update(
conn.execute(
f"SELECT p.id,p.category FROM prompts p " # nosec B608
f"WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
chunk,
).fetchall()
)
changed = [pid for pid, value in current.items() if value != category]
for pid in changed:
self._ensure_library_parent(conn, pid)
now = utc_now_iso()
conn.executemany(
"UPDATE prompts SET category=?,updated_at=? WHERE id=?",
[(category, now, pid) for pid in changed],
)
return {
"library_epoch": epoch,
"category": category,
"updated": len(changed),
"unchanged": len(current) - len(changed),
"not_found": len(prompt_ids) - len(current),
}
def bulk_add_library_tags(
self, prompt_ids: list[int], tags: list[str], *, expected_epoch: str
) -> dict:
"""Add tags to many Library parents, all or nothing (#442).
Names are normalized once (trim, the vocabulary name rules, casefold
repeats folded, at most 20). A parent is given only the names it lacks,
compared with ``casefold`` against its own tags; registry rows are
found and inserted by their exact name, and only for names some parent
gets. A parent whose tags would pass 20 is skipped (``over_limit``) so
the editor can still save it. Pair and image tags are not touched.
"""
from .annotations import ImageTagLimitError, normalize_image_tags
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
prompt_ids = list(dict.fromkeys(prompt_ids))
too_many = f"At most {MAX_LIBRARY_TAGS} tags can be added at once"
try:
tags = normalize_image_tags(tags)
except ImageTagLimitError:
# Its message names images; this is a prompt route.
raise ValueError(too_many) from None
if not tags:
raise ValueError("tags must name at least one tag")
if len(tags) > MAX_LIBRARY_TAGS:
raise ValueError(too_many)
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
held = self._library_parent_tags(conn, prompt_ids)
additions, over_limit = {}, 0
for pid, names in held.items():
present = {name.casefold() for name in names}
missing = [name for name in tags if name.casefold() not in present]
if not missing:
continue
if len(names) + len(missing) > MAX_LIBRARY_TAGS:
over_limit += 1
continue
additions[pid] = missing
wanted = list(
dict.fromkeys(n for names in additions.values() for n in names)
)
tag_ids = self._ensure_tags(conn, wanted)
now = utc_now_iso()
for pid, names in additions.items():
conn.executemany(
"INSERT OR IGNORE INTO prompt_tags (prompt_id, tag_id) "
"VALUES (?, ?)",
[(pid, tag_ids[name]) for name in names],
)
self._ensure_library_parent(conn, pid)
conn.execute("UPDATE prompts SET updated_at=? WHERE id=?", (now, pid))
return {
"library_epoch": epoch,
"tags": tags,
"updated": len(additions),
"unchanged": len(held) - len(additions) - over_limit,
"not_found": len(prompt_ids) - len(held),
"over_limit": over_limit,
}
def _library_parent_tags(
self, conn: sqlite3.Connection, prompt_ids: list[int]
) -> dict[int, list[str]]:
"""The Library parents among ``prompt_ids`` and their own tag names."""
held: dict[int, list[str]] = {}
for start in range(0, len(prompt_ids), _BULK_CHUNK):
chunk = prompt_ids[start : start + _BULK_CHUNK]
marks = ",".join("?" for _ in chunk)
for (pid,) in conn.execute(
f"SELECT p.id FROM prompts p WHERE p.id IN ({marks}) " # nosec B608
f"AND {_ELIGIBLE}",
chunk,
):
held[pid] = []
for pid, name in conn.execute(
"SELECT pt.prompt_id,t.name FROM prompt_tags pt " # nosec B608
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.prompt_id IN ({marks})",
chunk,
):
if pid in held:
held[pid].append(name)
return held
-114
View File
@@ -1,114 +0,0 @@
"""Library output-folder filter (#444): folder names, root prefixes and listing.
A folder is a ``/``-joined path relative to an output root, as Images' folder
filter and the legacy ``/prompt_manager/subfolders`` name them. A parent
matches a folder when one of its linked images (``generated_images.prompt_id``,
the link its previews and image count read) lies in that folder or below it,
under any spelling of any output root.
Matching compares ``generated_images.file_path``, which is always
``normcase(normpath(abspath(path)))`` (``models.normalize_image_path``), with
prefixes built by the same normalization here, so case folding is the
platform's own on both sides (Windows folds non-ASCII too, unlike SQLite's
``lower()``) and the comparison is an exact ``substr`` with no wildcards.
"""
import os
import sqlite3
_MAX_FOLDER = 1024
_MAX_PREFIXES = 64
def normalize_folder(value: object, pathmod=os.path) -> str:
"""The folder as ``a/b``, outer slashes dropped; empty means no filter.
``/`` always separates. ``\\`` separates only on Windows (``ntpath``): on
POSIX it is an ordinary character of a folder's name, and the listing
(``relative_folder``) names such a folder with it, so both agree.
``.``/``..``/empty segments, control characters and (on Windows) a drive
are refused: the value only ever names a folder below a root.
"""
if not isinstance(value, str) or len(value) > _MAX_FOLDER:
raise ValueError(f"folder must be text of at most {_MAX_FOLDER} characters")
windows = pathmod.sep == "\\"
folder = (value.replace("\\", "/") if windows else value).strip("/")
if not folder:
return ""
parts = folder.split("/")
if (
any(part in ("", ".", "..") for part in parts)
or (windows and ":" in parts[0])
or any(ord(char) < 32 or ord(char) == 127 for char in folder)
):
raise ValueError("folder must be a folder below an output root")
return folder
def _base(root: object, pathmod) -> str:
return pathmod.normcase(pathmod.normpath(str(root)))
def folder_prefixes(roots, folder: str, pathmod=os.path) -> list[str]:
"""One ``file_path`` prefix per root spelling for a normalized folder."""
prefixes = []
for root in roots:
joined = pathmod.join(_base(root, pathmod), *folder.split("/"))
prefix = pathmod.normcase(pathmod.normpath(joined)) + pathmod.sep
if prefix not in prefixes:
prefixes.append(prefix)
if not prefixes or len(prefixes) > _MAX_PREFIXES:
raise ValueError("No output folder to filter by")
return prefixes
def folder_condition(prefixes: list[str]) -> tuple[str, list]:
"""A condition on alias ``p``: one linked image under any prefix."""
tests = " OR ".join("substr(fg.file_path,1,?)=?" for _ in prefixes)
params = [value for prefix in prefixes for value in (len(prefix), prefix)]
return (
"EXISTS(SELECT 1 FROM generated_images fg WHERE fg.prompt_id=p.id "
f"AND ({tests}))",
params,
)
def relative_folder(image_path: str, roots, pathmod=os.path) -> str | None:
"""The folder of *image_path* below the first root spelling holding it.
The spelling of the stored path is kept (its case on Windows); ``None``
for an image directly in a root or outside every root.
"""
directory = pathmod.normpath(pathmod.dirname(image_path))
folded = pathmod.normcase(directory)
for root in roots:
base = _base(root, pathmod)
start = base if base.endswith(pathmod.sep) else base + pathmod.sep
if not folded.startswith(start):
continue
# normcase keeps the length for ordinary names; else show the folded form.
rest = (directory if len(directory) == len(folded) else folded)[len(start) :]
parts = rest.split(pathmod.sep)
if any(part in ("", ".", "..") for part in parts):
return None
return "/".join(parts)
return None
def list_library_folders(
conn: sqlite3.Connection, roots, eligible: str, pathmod=os.path
) -> list[str]:
"""Every folder (and ancestor) holding an image of an eligible parent."""
folders = set()
for (image_path,) in conn.execute(
"SELECT DISTINCT g.image_path FROM generated_images g "
"JOIN prompts p ON p.id=g.prompt_id WHERE " + eligible
):
if not isinstance(image_path, str) or not image_path:
continue
folder = relative_folder(image_path, roots, pathmod)
if folder is None:
continue
parts = folder.split("/")
folders.update("/".join(parts[:index]) for index in range(1, len(parts) + 1))
return sorted(folders, key=lambda value: (value.casefold(), value))
+1 -27
View File
@@ -56,11 +56,9 @@ from .models import BUSY_TIMEOUT_SECONDS, PromptModel
try:
from ..utils import library_location as ll
from ..utils.logging_config import get_logger
from ..utils.workspace_keys import KEY_FILE_NAME
except ImportError:
from utils import library_location as ll
from utils.logging_config import get_logger
from utils.workspace_keys import KEY_FILE_NAME
logger = get_logger("prompt_manager.database.library_move")
@@ -493,8 +491,7 @@ class _Move:
sweep_move_temps(target)
if ll.read_marker(target).get("state") == ll.MARKER_PENDING:
# An earlier move stopped before it switched: its files are ours.
names = (ll.DATABASE_NAME, ll.CONFIG_NAME, KEY_FILE_NAME)
for name in names + self.plan.backups:
for name in (ll.DATABASE_NAME, ll.CONFIG_NAME) + self.plan.backups:
_remove(os.path.join(target, name))
for suffix in ("-wal", "-shm", "-journal"):
_remove(os.path.join(target, ll.DATABASE_NAME + suffix))
@@ -539,7 +536,6 @@ class _Move:
raise MoveFailed("copy-failed") from error
_check_copies([(self.plan.config_path, temp, expected)])
copies = copies + [(self.plan.config_path, temp, expected, ll.CONFIG_NAME)]
copies = copies + self._key_copy()
self.marker = write_marker(
target, ll.MARKER_PENDING, self.plan.db_path, self.plan.config_path
)
@@ -560,28 +556,6 @@ class _Move:
self.checkpoint("marked")
return files
def _key_copy(self) -> list:
"""The workspace key's verified copy (#430), or none: it is re-creatable.
Without it the moved library starts with a new key, so pages left open
across the restart refresh once; that never fails the move.
"""
source = os.path.join(self.plan.source_dir, KEY_FILE_NAME)
if not os.path.isfile(source):
return []
temp = self.temp(".key")
try:
expected = _hash_copy(source, temp)
_check_copies([(source, temp, expected)])
except (OSError, MoveFailed) as error:
_remove(temp)
logger.warning(
f"Library move: the workspace key was not copied ({error}); "
"open workspace pages refresh once after the restart"
)
return []
return [(source, temp, expected, KEY_FILE_NAME)]
def cleanup(self) -> None:
if self.moved:
return
+26 -163
View File
@@ -15,8 +15,6 @@ change:
``cleanup_orphaned_images``), counted only under a source that can be read
now, as the health check's ``missing_files`` judges them
(``health.link_states``). It deletes database rows only, never a file.
It also forgets the removal marks (#480) that hide nothing any more: the
file is gone or changed, under a readable source (#501, ``_stale_marks``).
``preview_maintenance`` reads one snapshot on its own ``mode=ro`` connection
(``health.readonly_uri``) and closes it in ``finally``; files are looked at
@@ -43,16 +41,9 @@ from pathlib import Path
from .execution_contexts import LIVE_HINT_CTES
from .health import BATCH_ROWS, link_states, readable_roots, readonly_uri
from .image_removal_schema import TABLE as MARKS_TABLE
from .image_removal_schema import clear_marks, has_marks_table
from .pairs import PairDependencyError
from .workflow_documents import documents_of_rows, release_documents
try:
from ..utils.hashing import register_prompt_functions
except ImportError:
from utils.hashing import register_prompt_functions
MAINTENANCE_OPERATIONS = (
"duplicate_prompts",
"optimize",
@@ -89,12 +80,6 @@ PROTECTIONS = (
"with Library state (a title or an explicit parent)",
"with Library state (a title or an explicit parent)",
),
(
# #443: an archived prompt is restorable, never pruned.
"EXISTS (SELECT 1 FROM prompt_archive pa WHERE pa.prompt_id = p.id)",
"archived in the Library",
"archived in the Library",
),
(
"p.id IN (SELECT prompt_id FROM live_hint_prompts)",
"used by recorded runs",
@@ -103,8 +88,7 @@ PROTECTIONS = (
)
# Exempt references: images, pairs, the __protected__ tag, Library parent
# state, the Library archive, and live recorded-run hints (computed once,
# see LIVE_HINT_CTES).
# state, and live recorded-run hints (computed once, see LIVE_HINT_CTES).
ORPHANED_PROMPTS_SQL = (
"WITH "
+ LIVE_HINT_CTES
@@ -198,8 +182,6 @@ class MaintenanceDraft:
protected: dict
media: dict
details: list = field(default_factory=list)
# missing_links only: locators of removal marks whose file is gone (#501).
marks: list = field(default_factory=list)
def public(self):
"""The plan fields a response may carry (no fingerprint, no targets)."""
@@ -302,96 +284,11 @@ def _delete_ids(conn, table, ids):
return removed
def _mark_rows(conn, only=None):
"""``[(locator, size, mtime_ns)]`` of the removal marks (all, or those
among *only*), sorted by locator."""
if not has_marks_table(conn):
return []
sql = f"SELECT locator, size, mtime_ns FROM {MARKS_TABLE}" # nosec B608
if only is None:
return sorted(tuple(row) for row in conn.execute(sql))
found = []
for batch in _batches(sorted(only)):
found.extend(
tuple(row)
for row in conn.execute(
f"{sql} WHERE locator IN ({_marks(batch)})", batch # nosec B608
)
)
return sorted(found)
def _lists_something(folder):
"""Whether *folder* holds at least one entry (an unplugged drive's mount
point, or an unbound volume, is an empty folder that reads fine)."""
try:
with os.scandir(folder) as entries:
return next(entries, None) is not None
except OSError:
return False
def _name_listed(path):
"""Whether *path*'s folder lists its name, compared under ``normcase``.
A locator is ``normcase``d; on a case-sensitive NTFS folder ``lstat`` of
the lower-cased spelling fails for a file that is there.
"""
folder, name = os.path.split(path)
wanted = os.path.normcase(name)
try:
with os.scandir(folder) as entries:
return any(os.path.normcase(entry.name) == wanted for entry in entries)
except OSError:
return True # cannot tell: keep the mark
def _stale_mark(locator, size, mtime_ns):
"""Whether the file of a mark is gone or has other bytes (blocking)."""
try:
info = os.lstat(locator)
except (FileNotFoundError, NotADirectoryError):
return os.path.isdir(os.path.dirname(locator)) and not _name_listed(locator)
except OSError:
return False
return (info.st_size, info.st_mtime_ns) != (size, mtime_ns)
def _stale_marks(marks, roots):
"""``[(locator, owner)]`` of the marks that hide nothing any more (#501).
*marks* are ``(locator, size, mtime_ns)`` rows. A mark is stale when its
file was changed, or is gone from a folder that still exists. Only marks
under a source that can be read now and lists something are judged (an
unplugged drive's marks, or its empty mount point's, stay); any doubt
keeps the mark. *owner* is the index of the longest source holding it.
Locators are ``normcase(realpath)`` paths, so the sources are too.
"""
readable = readable_roots(roots)
prefixes = [
os.path.normcase(os.path.realpath(root)).rstrip("\\/") + os.sep
for root in roots
]
judged = {}
stale = []
for locator, size, mtime_ns in marks:
owners = [i for i, prefix in enumerate(prefixes) if locator.startswith(prefix)]
if not owners:
continue # under no source now (a source removed from the settings)
owner = max(owners, key=lambda i: len(prefixes[i]))
if owner not in judged:
judged[owner] = readable[owner] and _lists_something(roots[owner])
if judged[owner] and _stale_mark(locator, size, mtime_ns):
stale.append((locator, owner))
return stale
def _read_snapshot(db_path, read):
"""``read(conn)`` on one ``mode=ro`` snapshot; the connection always closes."""
conn = sqlite3.connect(readonly_uri(db_path), uri=True)
try:
conn.row_factory = sqlite3.Row
register_prompt_functions(conn) # LIVE_HINT_CTES calls them (#460)
conn.execute("BEGIN") # one read snapshot for the whole plan
return read(conn)
finally:
@@ -528,11 +425,8 @@ class MaintenanceOperations:
if operation == "workflow_storage":
return self._plan_workflow_storage()
if operation == "missing_links":
rows, marks = _read_snapshot(
self.model.db_path,
lambda conn: (self._link_rows(conn), _mark_rows(conn)),
)
return self._plan_missing_links(rows, roots, _stale_marks(marks, roots))
rows = _read_snapshot(self.model.db_path, self._link_rows)
return self._plan_missing_links(rows, roots)
reader = (
self._plan_duplicate_prompts
if operation == "duplicate_prompts"
@@ -663,15 +557,13 @@ class MaintenanceOperations:
)
@staticmethod
def _links_fingerprint(candidates, stale=()):
def _links_fingerprint(candidates):
fingerprint = _Fingerprint("missing_links")
for row, owner in candidates:
fingerprint.add([row[0], row[1], row[3], owner])
for locator, owner in stale:
fingerprint.add(["mark", locator, owner])
return fingerprint.hexdigest()
def _plan_missing_links(self, rows, roots, stale=()):
def _plan_missing_links(self, rows, roots):
readable = readable_roots(roots)
candidates, unavailable, outside = [], 0, 0
for row, owner, state in link_states(rows, roots, readable):
@@ -699,47 +591,29 @@ class MaintenanceOperations:
)
)
count = len(candidates)
parts = []
if count:
parts.append(
_plural(count, "link to a missing file", "links to missing files")
)
if stale:
parts.append(
_plural(
len(stale),
"removed image whose file is gone or changed",
"removed images whose files are gone or changed",
)
)
details = [
{
"label": _relative(row[1], roots[owner]),
"detail": f"{_source_name(roots[owner], owner)} · the file is missing",
}
for row, owner in candidates
] + [
{
"label": _relative(locator, os.path.realpath(roots[owner])),
"detail": (
f"{_source_name(roots[owner], owner)} · removed image, "
"the file is gone or changed"
),
}
for locator, owner in stale
]
return MaintenanceDraft(
operation="missing_links",
fingerprint=self._links_fingerprint(candidates, stale),
fingerprint=self._links_fingerprint(candidates),
targets=[row[0] for row, _owner in candidates],
scope={
"count": count + len(stale),
"label": "; ".join(parts) or "No links to missing files",
"count": count,
"label": (
_plural(count, "link to a missing file", "links to missing files")
if count
else "No links to missing files"
),
},
protected={"count": unavailable + outside, "reasons": reasons},
media={"deletes_files": False, "label": LINKS_ONLY},
details=details[:MAX_DETAILS],
marks=[locator for locator, _owner in stale],
details=[
{
"label": _relative(row[1], roots[owner]),
"detail": (
f"{_source_name(roots[owner], owner)} · the file is missing"
),
}
for row, owner in candidates[:MAX_DETAILS]
],
)
def _plan_optimize(self):
@@ -789,18 +663,14 @@ class MaintenanceOperations:
# ── apply ─────────────────────────────────────────────────────────
def apply_maintenance(
self, operation, fingerprint, targets, roots=(), marks=()
) -> dict:
def apply_maintenance(self, operation, fingerprint, targets, roots=()) -> dict:
"""Apply a reviewed plan exactly, or raise PlanChanged (blocking).
Inside ``BEGIN IMMEDIATE`` the fingerprint is computed again on the
write connection; when it differs nothing is written. ``targets`` and
``roots`` are the plan's. Returns ``{changed, rows}``: how many
records changed and up to ``MAX_RESULT_ROWS`` descriptions of them
(``{prompt_id[, kept_id]}`` or ``{image_path}``). ``marks`` are a
missing-links plan's stale removal marks; that apply also returns
``marks``, how many it forgot.
(``{prompt_id[, kept_id]}`` or ``{image_path}``).
"""
if operation not in MAINTENANCE_OPERATIONS or operation == "workflow_storage":
raise ValueError(f"Unknown maintenance operation {operation!r}")
@@ -813,12 +683,9 @@ class MaintenanceOperations:
appliers = {
"duplicate_prompts": self._apply_duplicate_prompts,
"unused_components": self._apply_unused_components,
"missing_links": self._apply_missing_links,
}
with self._pair_transaction() as conn:
if operation == "missing_links":
return self._apply_missing_links(
conn, fingerprint, targets, roots, list(marks)
)
return appliers[operation](conn, fingerprint, targets, roots)
def _apply_duplicate_prompts(self, conn, fingerprint, targets, _roots):
@@ -847,7 +714,7 @@ class MaintenanceOperations:
]
return {"changed": changed, "rows": rows}
def _apply_missing_links(self, conn, fingerprint, targets, roots, marks):
def _apply_missing_links(self, conn, fingerprint, targets, roots):
rows = []
for batch in _batches(sorted(targets)):
rows.extend(
@@ -864,14 +731,10 @@ class MaintenanceOperations:
if state != "missing": # back, or its source went away
raise PlanChanged("missing_links")
candidates.append((row, owner))
stale = _stale_marks(_mark_rows(conn, marks) if marks else [], roots)
if [locator for locator, _owner in stale] != marks: # a mark went or is valid
raise PlanChanged("missing_links")
if self._links_fingerprint(candidates, stale) != fingerprint:
if self._links_fingerprint(candidates) != fingerprint:
raise PlanChanged("missing_links")
changed = _delete_ids(conn, "generated_images", [row[0] for row in rows])
return {
"changed": changed,
"marks": clear_marks(conn, marks),
"rows": [{"image_path": row[1]} for row in rows[:MAX_RESULT_ROWS]],
}
-24
View File
@@ -38,14 +38,7 @@ from .review_schema import (
review_without_annotations_error,
)
from .saved_views import migrate_saved_views, saved_views_schema_error
try:
from ..utils.hashing import register_prompt_functions
except ImportError:
from utils.hashing import register_prompt_functions
from .image_categories import image_categories_schema_error, migrate_image_categories
from .image_removal_schema import image_removals_schema_error, migrate_image_removals
from .prompt_archive import migrate_prompt_archive, prompt_archive_schema_error
from .workflow_documents import (
DOCUMENT_ID_COLUMNS,
migrate_workflow_documents,
@@ -160,15 +153,12 @@ def configure_connection(conn: sqlite3.Connection) -> None:
(common on Windows) let other writers' polling busy handlers starve past
BUSY_TIMEOUT_SECONDS and fail with "database is locked" (#242). Without
WAL (for example an in-memory database) SQLite's FULL default is kept.
It also registers the prompt-key SQL functions (register_prompt_functions).
"""
journal_mode = conn.execute("PRAGMA journal_mode = WAL").fetchone()[0]
conn.execute("PRAGMA foreign_keys = ON")
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_SECONDS * 1000}")
if str(journal_mode).lower() == "wal":
conn.execute("PRAGMA synchronous = NORMAL")
# History reads and the live-hint rule call Python's prompt key (#460)
register_prompt_functions(conn)
class PromptModel:
@@ -368,10 +358,6 @@ class PromptModel:
migrate_autotag_state(conn)
# Image categories (#332): independent of every table above.
migrate_image_categories(conn)
# The Library parent archive (#443): independent of every table above.
migrate_prompt_archive(conn)
# Removal marks (#480): independent of every table above.
migrate_image_removals(conn)
# Last: the older generated_images rebuilds run only before file_path.
migrate_workflow_documents(conn)
@@ -1248,16 +1234,6 @@ class PromptModel:
)
if categories_error:
return False, categories_error
archive_error = prompt_archive_schema_error(
conn, allow_missing=not require_library_state
)
if archive_error:
return False, archive_error
removals_error = image_removals_schema_error(
conn, allow_missing=not require_library_state
)
if removals_error:
return False, removals_error
views_error = saved_views_schema_error(
conn, allow_missing=not require_library_state
)
+8 -11
View File
@@ -18,14 +18,12 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations # noqa: F401 (re-exported)
from .library import LibraryOperations, titled
from .image_favorites import ImageFavoriteOperations
from .image_removal import ImageRemovalOperations, removed_from_library
from .image_collections import ImageCollectionOperations
from .annotations import AnnotationOperations
from .review import ReviewOperations
from .review_decisions import ReviewDecisionOperations
from .saved_views import SavedViewOperations
from .library_commands import LibraryCommandOperations
from .library_archive import LibraryArchiveOperations, delete_prompt_rows
from .vocabulary import VocabularyOperations
from .execution_history import ExecutionHistoryOperations
from .execution_contexts import ExecutionContextOperations
@@ -450,7 +448,6 @@ class PromptDatabase(
PairOperations,
LibraryOperations,
LibraryCommandOperations,
LibraryArchiveOperations,
VocabularyOperations,
ExecutionHistoryOperations,
ExecutionContextOperations,
@@ -468,7 +465,6 @@ class PromptDatabase(
WorkflowStorageOperations,
CategoryImportOperations,
ImageCategoryOperations,
ImageRemovalOperations,
):
"""Database operations class for managing prompts."""
@@ -1810,11 +1806,6 @@ class PromptDatabase(
"""
if not isinstance(metadata, dict):
metadata = {}
if removed_from_library(conn, image_path):
# #480: the user removed this exact file from the library; its
# bytes are unchanged, so nothing links it again.
self.logger.debug("Not linking a file removed from the library")
return 0
filename = os.path.basename(image_path)
file_path = normalize_image_path(image_path)
file_size, width, height, image_format = _coerce_file_info(metadata)
@@ -2353,10 +2344,16 @@ class PromptDatabase(
Returns:
Number of prompts actually deleted.
"""
count = 0
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, prompt_ids)
# The workspace Library's delete runs the same statements (#443).
count = delete_prompt_rows(conn, list(dict.fromkeys(prompt_ids)))
documents = documents_of_rows(conn, "prompt_id", 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,))
if cursor.rowcount > 0:
count += 1
release_documents(conn, documents)
conn.commit()
return count
-107
View File
@@ -1,107 +0,0 @@
"""Library parent archive (#443, D152): prompts hidden from the shelf, restorable.
A row means the Library parent is archived; no row means it is on the shelf.
Nothing else about the prompt changes: its text, metadata, tags, run count,
image links, pairs and recorded runs stay. Restore deletes the row. Deleting
the prompt (any path) drops the row by cascade.
A table of its own, not a column: ``prompt_library_state`` is validated by its
exact definition, so a new column there would make older builds refuse the
upgraded database, while older builds ignore an unknown table (they show the
archived prompts again and keep the rows). Optional in old backups, required
once a database is current.
"""
import re
import sqlite3
TABLE = "prompt_archive"
MAX_STAMP_BYTES = 64
# What ``utc_now_iso`` writes starts with a date; anything else was not written here.
_STAMP_GLOB = "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*"
_DDL = (
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
"archived_at TEXT NOT NULL, "
"FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
)
_COLUMNS = (("prompt_id", "INTEGER", 0, 1), ("archived_at", "TEXT", 1, 0))
_FOREIGN_KEYS = {("prompt_id", "prompts", "id", "CASCADE")}
# A predicate on ``p`` (the prompts alias): the parent is not archived.
SHELVED = f"NOT EXISTS(SELECT 1 FROM {TABLE} pa WHERE pa.prompt_id=p.id)"
ARCHIVED = f"EXISTS(SELECT 1 FROM {TABLE} pa WHERE pa.prompt_id=p.id)"
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql or "").lower()
def _structure_error(conn: sqlite3.Connection, allow_missing: bool) -> str | None:
objects = conn.execute(
"SELECT type,sql FROM sqlite_master WHERE name=? "
"OR (type IN ('index','trigger') AND tbl_name=?)",
(TABLE, TABLE),
).fetchall()
if not objects:
return None if allow_missing else "Missing prompt archive schema"
tables = [sql for kind, sql in objects if kind == "table"]
if len(tables) != 1 or len(objects) != 1:
return "Prompt archive name is reserved by an unsupported schema object"
if _compact(tables[0]) != _compact(_DDL):
return "Prompt archive has an invalid table definition"
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_info({TABLE})")
)
if columns != _COLUMNS:
return "Prompt archive has invalid columns"
foreign_keys = {
(row[3], row[2], row[4], row[6])
for row in conn.execute(f"PRAGMA foreign_key_list({TABLE})")
}
if foreign_keys != _FOREIGN_KEYS:
return "Prompt archive has invalid foreign key definitions"
return None
def prompt_archive_schema_error(
conn: sqlite3.Connection, *, allow_missing: bool = True
) -> str | None:
"""Reject an unsupported archive schema or values; reads only.
A missing table is acceptable for backups that predate it unless
``allow_missing`` is false.
"""
error = _structure_error(conn, allow_missing)
if error is not None:
return error
if not conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
).fetchone():
return None
if conn.execute(f"PRAGMA foreign_key_check({TABLE})").fetchone():
return "Prompt archive contains foreign key violations"
if conn.execute(
f"SELECT 1 FROM {TABLE} WHERE typeof(archived_at)!='text' " # nosec B608
f"OR length(CAST(archived_at AS BLOB))>{MAX_STAMP_BYTES} "
f"OR archived_at NOT GLOB '{_STAMP_GLOB}' LIMIT 1"
).fetchone():
return "Prompt archive contains invalid values"
return None
def migrate_prompt_archive(conn: sqlite3.Connection) -> None:
"""Install the table atomically; validate before and after (no backfill)."""
conn.execute("SAVEPOINT prompt_archive_schema")
try:
error = _structure_error(conn, True)
if error:
raise sqlite3.DatabaseError(error)
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
error = _structure_error(conn, False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE prompt_archive_schema")
except BaseException:
conn.execute("ROLLBACK TO prompt_archive_schema")
conn.execute("RELEASE prompt_archive_schema")
raise
+1 -8
View File
@@ -149,7 +149,6 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
collections (#331).
"""
from .library import _ELIGIBLE
from .prompt_archive import SHELVED, TABLE as ARCHIVE
has_state = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND "
@@ -158,15 +157,9 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
has_pairs = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='prompt_pairs'"
).fetchone()
has_archive = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (ARCHIVE,)
).fetchone()
if has_state and has_pairs:
# Archived parents (#443) are off the shelf, as in library_counts.
positives = conn.execute(
"SELECT COUNT(*) FROM prompts p WHERE "
+ _ELIGIBLE
+ (" AND " + SHELVED if has_archive else "")
"SELECT COUNT(*) FROM prompts p WHERE " + _ELIGIBLE
).fetchone()[0]
else:
positives = _count(conn, "prompts")
-11
View File
@@ -33,9 +33,6 @@ MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
# How a Library view's Tags match (#444): every tag, any of them, or parents
# without tags ("untagged" never goes with tags; the listing refuses both).
_TAG_MODES = ("all", "any", "untagged")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
# A collection id as the listing's ``collection`` query takes it (#331): none
@@ -70,9 +67,6 @@ _STATE_FIELDS = {
"category": ("text", 100, ""),
"sort": ("choice", _LIBRARY_SORTS, "newest"),
"tags": ("tags", None, ()),
# #444: the Tags match and the output folder (relative, "/"-joined).
"tag_mode": ("choice", _TAG_MODES, "all"),
"folder": ("text", 1024, ""),
},
}
@@ -158,8 +152,6 @@ def encode_state(page: object, state: object) -> str:
_validate_value(key, kind, limit, state[key])
if state.get("favorites") is True and state.get("collection"):
raise SavedViewInvalid("A view cannot filter by favorites and a collection")
if state.get("tag_mode") == "untagged" and state.get("tags"):
raise SavedViewInvalid("A view cannot list untagged prompts and filter tags")
encoded = json.dumps(
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
)
@@ -203,9 +195,6 @@ def normalize_state(page: str, raw: object) -> dict:
# as the listing does for a URL with both (#331 D140).
if state.get("favorites") is True and state.get("collection"):
state["favorites"] = False
# Never written by this build either: the tags win.
if state.get("tag_mode") == "untagged" and state.get("tags"):
state["tag_mode"] = "all"
return state
+19 -159
View File
@@ -20,8 +20,7 @@ counted, hashed into the token and renamed with the prompts and pairs.
import sqlite3
from .library import _ELIGIBLE, SHELVED_PAIR, UNTAGGED, _escape, _limit
from .prompt_archive import SHELVED
from .library import _ELIGIBLE, _limit
from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch
from .library_tokens import canonical_token
from .models import utc_now_iso
@@ -39,76 +38,30 @@ _IMAGE_FILES = (
)
def _tag_rows(page: str, order: str = "page.id") -> str:
def _tag_rows(page: str) -> str:
"""Count rows for the registry rows selected by ``page`` (id, name)."""
# Eligible parent components on the shelf carrying a tag in the page,
# plus the positive parent of every active pair carrying it (always
# eligible) when that parent is on the shelf: archived parents (#443)
# are hidden with their pairs, so Browse lists what was counted.
# Eligible parent components carrying a tag in the page, plus the
# positive parent of every active pair carrying it (always eligible).
return (
"WITH page AS (" + page + "),"
"owners(tag_id,pid) AS ("
"SELECT pt.tag_id,p.id FROM prompts p "
"JOIN prompt_tags pt ON pt.prompt_id=p.id "
"WHERE pt.tag_id IN (SELECT id FROM page) AND " + _ELIGIBLE + " "
"AND " + SHELVED + " "
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id "
"WHERE pp.archived=0 AND " + SHELVED_PAIR + " "
"AND ppt.tag_id IN (SELECT id FROM page)),"
"WHERE pp.archived=0 AND ppt.tag_id IN (SELECT id FROM page)),"
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
"SELECT page.id,page.name,COUNT(DISTINCT owners.pid) AS prompt_count,"
"COALESCE(MAX(images.n),0) AS image_count "
"FROM page LEFT JOIN owners ON owners.tag_id=page.id "
"LEFT JOIN images ON images.tag_id=page.id "
"GROUP BY page.id ORDER BY " + order
"GROUP BY page.id ORDER BY page.id"
)
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
# "created" follow the id, so every order is total and keyset pages are exact.
TAG_SORTS = {
"created": (None, "ASC"),
"name": ("name COLLATE NOCASE", "ASC"),
"name-desc": ("name COLLATE NOCASE", "DESC"),
"prompts": ("prompt_count", "DESC"),
"prompts-asc": ("prompt_count", "ASC"),
}
_MAX_SEARCH = 200
_TAG_SEARCH = "name LIKE ? ESCAPE '\\'"
# Every matching tag ranked by the prompt count its row shows (the owners rule
# of ``_tag_rows``), then one keyset page of it with its image counts.
_RANKED_TAG_PAGE = (
"WITH owners(tag_id,pid) AS ("
"SELECT pt.tag_id,p.id FROM prompts p "
"JOIN prompt_tags pt ON pt.prompt_id=p.id WHERE " + _ELIGIBLE + " "
"AND " + SHELVED + " "
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0 "
"AND " + SHELVED_PAIR + "),"
"counted AS (SELECT t.id,t.name,COUNT(DISTINCT o.pid) AS prompt_count "
"FROM tags t LEFT JOIN owners o ON o.tag_id=t.id WHERE {search} "
"GROUP BY t.id),"
"page AS (SELECT id,name,prompt_count FROM counted WHERE {after} "
"ORDER BY prompt_count {direction},id LIMIT ?),"
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
"SELECT page.id,page.name,page.prompt_count,"
"COALESCE(images.n,0) AS image_count FROM page "
"LEFT JOIN images ON images.tag_id=page.id "
"ORDER BY page.prompt_count {direction},page.id"
)
_UNTAGGED_COUNT = (
"SELECT COUNT(*) FROM prompts p WHERE ("
+ _ELIGIBLE
+ ") AND "
+ UNTAGGED
# Untagged prompts opens the shelf, which hides archived parents (#443).
+ " AND "
+ SHELVED
)
_TAG_ONE = _tag_rows("SELECT id,name FROM tags WHERE id=?")
# Every owner kind a tag can sit on, for the delete confirmation.
_TAG_IMPACT = (
@@ -147,9 +100,7 @@ def _validate_tag_id(tag_id: object) -> None:
# Category values in Tags & categories (#332 §7): the prompt-backed values
# (nonblank values on eligible parents or on active pairs; ``prompt_count``
# counts only parents on the shelf, so a value only archived parents (#443)
# hold stays listed with 0, as a tag does; grouped exactly, so
# (nonblank values on eligible parents or on active pairs, grouped exactly, so
# whitespace and case variants stay distinct; /library/options lists the same
# prompt-backed set with its own query) plus the values only image rows hold.
# A prompt-backed value is ordered by its oldest owning positive; an image-only
@@ -158,17 +109,15 @@ def _validate_tag_id(tag_id: object) -> None:
# files missing on disk. No FULL OUTER JOIN, so it runs on SQLite 3.31+.
_IMAGE_ONLY_FIRST_PID = 2**63 - 1
_CATEGORY_ROWS = (
"WITH owners(value,pid,shelved) AS ("
"SELECT p.category,p.id," + SHELVED + " FROM prompts p "
"WHERE p.category IS NOT NULL AND TRIM(p.category)!='' AND " + _ELIGIBLE + " "
"UNION SELECT pp.category,pp.positive_prompt_id," + SHELVED_PAIR + " "
"FROM prompt_pairs pp "
"WITH owners(value,pid) AS ("
"SELECT p.category,p.id FROM prompts p WHERE p.category IS NOT NULL "
"AND TRIM(p.category)!='' AND " + _ELIGIBLE + " "
"UNION SELECT pp.category,pp.positive_prompt_id FROM prompt_pairs pp "
"WHERE pp.archived=0 AND pp.category IS NOT NULL AND TRIM(pp.category)!=''),"
"image_values(value,image_count) AS ("
"SELECT category,COUNT(*) FROM image_categories GROUP BY category),"
"grouped(value,first_pid,prompt_count) AS ("
"SELECT value,MIN(pid),COUNT(DISTINCT CASE WHEN shelved THEN pid END) "
"FROM owners GROUP BY value "
"SELECT value,MIN(pid),COUNT(DISTINCT pid) FROM owners GROUP BY value "
"UNION ALL SELECT value," + str(_IMAGE_ONLY_FIRST_PID) + ",0 FROM image_values "
"WHERE value NOT IN (SELECT value FROM owners)),"
"counted AS (SELECT g.value,g.first_pid,g.prompt_count,"
@@ -261,28 +210,6 @@ def _tag_after(after: object) -> int:
return after
def _tag_search(search: object) -> str:
if not isinstance(search, str) or len(search) > _MAX_SEARCH:
raise ValueError(f"Tag search must be text of at most {_MAX_SEARCH} characters")
return search.strip()
def _sorted_tag_after(after: object, sort: str) -> tuple | None:
"""The ``[key, id]`` cursor of a name or prompt-count sort."""
if after is None:
return None
key_type = str if sort.startswith("name") else int
if (
not isinstance(after, list)
or len(after) != 2
or type(after[0]) is not key_type
or type(after[1]) is not int
or after[1] <= 0
):
raise ValueError("Invalid vocabulary cursor")
return after[0], after[1]
def _category_after(after: object) -> tuple[int, str] | None:
if after is None:
return None
@@ -504,80 +431,22 @@ class VocabularyOperations:
epoch: str,
*,
limit: int = 100,
after: object = None,
search: str = "",
sort: str = "created",
after: int | None = None,
) -> dict:
"""One page of tags matching ``search`` (a literal, case-insensitive
substring) in ``sort`` order, with the untagged parents' count."""
_limit(limit)
search = _tag_search(search)
if not isinstance(sort, str) or sort not in TAG_SORTS:
raise ValueError("sort must be one of " + ", ".join(TAG_SORTS))
where, params = "1=1", []
if search:
where, params = _TAG_SEARCH, [f"%{_escape(search)}%"]
total, untagged = conn.execute(
f"SELECT (SELECT COUNT(*) FROM tags WHERE {where}),({_UNTAGGED_COUNT})",
params,
).fetchone()
rows = [
dict(row) for row in self._tag_page(conn, where, params, sort, after, limit)
]
start = _tag_after(after)
total = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0]
rows = [dict(row) for row in conn.execute(_TAG_PAGE, (start, limit + 1))]
has_more = len(rows) > limit
rows = rows[:limit]
key, _direction = TAG_SORTS[sort]
next_key = None
if has_more:
last = rows[-1]
next_key = (
last["id"]
if key is None
else [
last["prompt_count" if key == "prompt_count" else "name"],
last["id"],
]
)
return {
"library_epoch": epoch,
"tags": rows,
"total": total,
"untagged_count": untagged,
# Echoed so a client can tell this read applied its search and sort.
"search": search,
"sort": sort,
"has_more": has_more,
"next_key": next_key,
"next_key": rows[-1]["id"] if has_more else None,
}
@staticmethod
def _tag_page(conn, where, params, sort, after, limit):
key, direction = TAG_SORTS[sort]
if key is None:
page = (
f"SELECT id,name FROM tags WHERE {where} AND id>? ORDER BY id LIMIT ?"
)
return conn.execute(
_tag_rows(page), [*params, _tag_after(after), limit + 1]
)
cursor = _sorted_tag_after(after, sort)
operator = ">" if direction == "ASC" else "<"
keyset, keys = "1=1", []
if cursor is not None:
keyset = f"({key}{operator}? OR ({key}=? AND id>?))"
keys = [cursor[0], cursor[0], cursor[1]]
if key == "prompt_count":
sql = _RANKED_TAG_PAGE.format(
search=where, after=keyset, direction=direction
)
return conn.execute(sql, [*params, *keys, limit + 1])
page = (
f"SELECT id,name FROM tags WHERE {where} AND {keyset} "
f"ORDER BY {key} {direction},id LIMIT ?"
)
order = f"page.{key} {direction},page.id"
return conn.execute(_tag_rows(page, order), [*params, *keys, limit + 1])
def _vocabulary_categories(
self,
conn: sqlite3.Connection,
@@ -621,18 +490,9 @@ class VocabularyOperations:
),
}
def list_vocabulary_tags(
self,
*,
limit: int = 100,
after: object = None,
search: str = "",
sort: str = "created",
):
def list_vocabulary_tags(self, *, limit: int = 100, after: int | None = None):
with self.library_snapshot() as (conn, epoch, _ceiling):
return self._vocabulary_tags(
conn, epoch, limit=limit, after=after, search=search, sort=sort
)
return self._vocabulary_tags(conn, epoch, limit=limit, after=after)
def list_vocabulary_categories(
self, *, limit: int = 100, after: list | None = None
+1 -2
View File
@@ -12,8 +12,7 @@ code. This plan proposes implementation order and acceptance gates.
- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates.
- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209).
- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196.
- Local Inspect (#249, PRs L1–L3) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, including its one-click COPY ALL summary, which is the shared inspector's "Copy settings summary" (D117, LI-4). PR L3 ([#286](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/286)) shipped that button and local roles equal to the server's resolver (`web/js/workspace/local-resolver.mjs`, TESTING.md "Local roles (#249 PR L3, decision LI-1)"). Recorded deviations D60–D66 and D117 are in [TESTING.md](../TESTING.md).
- Collections ([#331](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/331), three PRs) adds named, hand-picked groups of images, stored in the library database by file location as favorites are: a COLLECTIONS sidebar group whose rows open Images filtered to one collection (`#/collection/<id>`) with rename and delete, Add to collection and Remove from collection in the Images selection bar, a Collections… checklist in the shared inspector for the side panel and the viewer, and saved views that keep a collection. Membership survives restarts, source reordering and System › Maintenance duplicate cleanup; no file is copied, moved or changed.
- Local Inspect (#249, PRs L1–L2) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, except its one-click COPY ALL summary, whose ruled replacement ("Copy settings summary") is pending. Recorded deviations D60–D66 are in [TESTING.md](../TESTING.md). Local roles equal to the server's resolver follow in PR L3.
## Scope and compatibility
+1 -6
View File
@@ -275,12 +275,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
self._record_cached_item(
unique_id,
(encoding_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
self._record_cached_item(unique_id, (encoding_text,), storage_text)
return result
@classmethod
+3 -13
View File
@@ -243,24 +243,14 @@ class PromptManagerBase:
return False
def _record_cached_item(
self,
unique_id: Any,
outputs: tuple,
text: Any,
negative_text: Any = None,
fields: Optional[tuple] = None,
self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None
) -> None:
"""Remember this execution's outputs and stored text for cached jobs (#347).
``fields`` are the ``(raw, prepend, append)`` texts this execution gave
``record_runtime_context`` for each output, so a cached job's history
can record them too (#403).
"""
"""Remember this execution's outputs and stored text for cached jobs (#347)."""
if unique_id is None:
return
try:
self._cached_runs().record_item(
unique_id, outputs, stored_facts(text, negative_text), fields
unique_id, outputs, stored_facts(text, negative_text)
)
except Exception as error:
self.logger.debug(f"Could not record the cached-run receipt: {error}")
+1 -5
View File
@@ -198,11 +198,7 @@ class PromptManagerPair(PromptManagerFocused):
type(self).__name__, negative_text, "", "", negative_text, role="negative"
)
self._record_cached_item(
unique_id,
(positive, negative_text),
text,
negative_text,
fields=((text, prepend_text, append_text), (negative_text, "", "")),
unique_id, (positive, negative_text), text, negative_text
)
return result
+1 -6
View File
@@ -246,12 +246,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
self._record_cached_item(
unique_id,
(final_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
self._record_cached_item(unique_id, (final_text,), storage_text)
return result
@classmethod
+13 -42
View File
@@ -19,7 +19,6 @@ Split into domain-specific mixins for maintainability:
- SystemRoutesMixin: System health check, Overview and usage statistics
- LibraryLocationRoutesMixin: where the library lives, Move library, Keep it here
- MaintenanceRoutesMixin: reviewed maintenance plans (preview, apply)
- LibraryDeleteRoutesMixin: the Library's Delete… (archive paired) and Restore
- WorkflowStorageRoutesMixin: store each workflow once, then Reclaim space
- DuplicateFilesMixin: duplicate image files as find and removal jobs
- LoraImportRoutesMixin: the reviewed, staged LoRA reference import job
@@ -41,10 +40,8 @@ from aiohttp import web
from PIL import Image
from .prompts import PromptRoutesMixin
from .revalidation import revalidated_response
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .library_delete import LibraryDeleteRoutesMixin
from .vocabulary import VocabularyRoutesMixin
from .annotations import AnnotationRoutesMixin
from .review import ReviewRoutesMixin
@@ -77,8 +74,6 @@ from .autotag_routes import AutotagRoutesMixin
from .lora_integration import LoraIntegrationMixin
from .lora_import_routes import LoraImportRoutesMixin
from .image_categories import ImageCategoryRoutesMixin
from .image_delete import ImageDeleteMixin
from .source_rescope import SourceRescopeMixin
try:
from ...database.operations import PromptDatabase
@@ -317,7 +312,6 @@ async def _gzip_middleware(request, handler):
class PromptManagerAPI(
PairRoutesMixin,
LibraryRoutesMixin,
LibraryDeleteRoutesMixin,
VocabularyRoutesMixin,
AnnotationRoutesMixin,
ReviewRoutesMixin,
@@ -348,8 +342,6 @@ class PromptManagerAPI(
LoraIntegrationMixin,
LoraImportRoutesMixin,
ImageCategoryRoutesMixin,
ImageDeleteMixin,
SourceRescopeMixin,
):
"""REST API handler for PromptManager operations and web interface.
@@ -429,12 +421,6 @@ class PromptManagerAPI(
# Reviewed maintenance plans under single-use tokens; a restart
# forgets them (py/api/maintenance.py).
self._maintenance_plans = PlanStore()
# #480: the removal marks the workspace listing hides, read once now so
# a listing page reads no table until a mark or the database changes.
try:
self._removal_marks()
except Exception:
self.logger.warning("Removal marks could not be read", exc_info=True)
# A safety backup interrupted by a crash leaves its temp copy behind.
try:
@@ -665,45 +651,40 @@ class PromptManagerAPI(
@routes.get("/prompt_manager/workspace")
async def serve_workspace_ui(request: web.Request) -> web.Response:
return self._serve_html_page(request, "workspace.html", "Workspace")
return self._serve_html_page("workspace.html", "Workspace")
@routes.get("/prompt_manager/web")
async def serve_web_ui(request):
return self._serve_html_page(request, "index.html", "Web UI", cache=False)
return self._serve_html_page("index.html", "Web UI", cache=False)
@routes.get("/prompt_manager/gallery.html")
async def serve_gallery_ui(request):
return self._serve_html_page(request, "metadata.html", "Gallery")
return self._serve_html_page("metadata.html", "Gallery")
@routes.get("/prompt_manager/admin")
async def serve_admin_ui(request):
return self._serve_html_page(request, "admin.html", "Admin UI")
return self._serve_html_page("admin.html", "Admin UI")
@routes.get("/prompt_manager/gallery")
async def serve_gallery_admin_ui(request):
return self._serve_html_page(request, "gallery.html", "Gallery")
return self._serve_html_page("gallery.html", "Gallery")
# ── Static file serving ───────────────────────────────────────
@routes.get("/prompt_manager/lib/{filepath:.*}")
async def serve_lib_static(request):
"""Serve static library files (JS, CSS) from web/lib directory."""
return self._serve_static_file(
request, "lib", request.match_info.get("filepath")
)
return self._serve_static_file("lib", request.match_info.get("filepath"))
@routes.get("/prompt_manager/js/{filepath:.*}")
async def serve_js_static(request):
"""Serve static JavaScript files from web/js directory."""
return self._serve_static_file(
request, "js", request.match_info.get("filepath")
)
return self._serve_static_file("js", request.match_info.get("filepath"))
# ── Register domain-specific routes from mixins ───────────────
self._register_pair_routes(routes)
self._register_library_routes(routes)
self._register_library_delete_routes(routes)
self._register_vocabulary_routes(routes)
self._register_annotation_routes(routes)
self._register_review_routes(routes)
@@ -727,8 +708,6 @@ class PromptManagerAPI(
self._register_lora_routes(routes)
self._register_lora_import_routes(routes)
self._register_image_category_routes(routes)
self._register_image_delete_routes(routes)
self._register_rescope_routes(routes)
# Register gzip compression middleware (once)
global _gzip_registered
@@ -744,11 +723,8 @@ class PromptManagerAPI(
self.logger.info("All routes registered with decorator pattern")
def _serve_static_file(self, request, subdir, filepath):
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing.
A served file is revalidated on every reuse (#478, ``revalidation``).
"""
def _serve_static_file(self, subdir, filepath):
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
root = os.path.join(_get_project_root(), "web", subdir)
exact = STATIC_EXACT_FILES.get((subdir, filepath))
# An exact file passes the same containment check, its extension only.
@@ -763,13 +739,10 @@ class PromptManagerAPI(
return web.Response(text="Not Found", status=404)
with open(file_path, "rb") as f:
content = f.read()
return revalidated_response(request, content, content_type)
return web.Response(body=content, content_type=content_type)
def _serve_html_page(self, request, filename, title, cache=True):
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable.
A served page is revalidated on every reuse (#478, ``revalidation``).
"""
def _serve_html_page(self, filename, title, cache=True):
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
html_path = os.path.join(_get_project_root(), "web", filename)
try:
if cache and html_path in self._html_cache:
@@ -786,9 +759,7 @@ class PromptManagerAPI(
content_type="text/html",
status=404,
)
return revalidated_response(
request, content.encode("utf-8"), "text/html", charset="utf-8"
)
return web.Response(text=content, content_type="text/html", charset="utf-8")
except Exception:
self.logger.exception(f"Failed to load {title}")
return web.Response(
+7 -22
View File
@@ -36,13 +36,10 @@ except ImportError:
try:
from ...utils import exit_signal
from ...utils.media_folders import is_skipped_folder
except ImportError:
from utils import exit_signal
from utils.media_folders import is_skipped_folder
try:
from ...database.image_removal import mark_hides
from ...database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
from ...database.run_attribution import (
RecordedOutput,
@@ -63,7 +60,6 @@ try:
decide_file_prompt,
)
except ImportError:
from database.image_removal import mark_hides
from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
from database.run_attribution import (
RecordedOutput,
@@ -122,11 +118,10 @@ DUPLICATE_FIND_LEGACY_TITLE = "Find duplicate image files (gallery page)"
def _collect_media_files(output_dirs, extensions):
"""Media files under ``output_dirs`` (blocking), bounded and symlink-free.
Walks each root without following symlinks, skips ``thumbnails`` and
trash folders (``utils.media_folders``) and symlinked files, never
descends more than MAX_SCAN_DEPTH levels and stops after MAX_SCAN_FILES
files in total. Extensions match case-insensitively and each path is
listed once.
Walks each root without following symlinks, skips ``thumbnails``
directories and symlinked files, never descends more than MAX_SCAN_DEPTH
levels and stops after MAX_SCAN_FILES files in total. Extensions match
case-insensitively and each path is listed once.
"""
wanted = {ext.lower() for ext in extensions}
found = []
@@ -141,7 +136,7 @@ def _collect_media_files(output_dirs, extensions):
dirnames[:] = sorted(
d
for d in dirnames
if not is_skipped_folder(d)
if d != "thumbnails"
and depth < MAX_SCAN_DEPTH
and not os.path.islink(os.path.join(current, d))
)
@@ -888,8 +883,6 @@ class AdminRoutesMixin:
extensions = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS
media_files = []
seen = set()
# #480: a file removed from the library is never a copy or a keeper.
marks = self._removal_marks()
for slot, root in enumerate(roots):
for path in _collect_media_files([root], extensions):
try:
@@ -898,9 +891,6 @@ class AdminRoutesMixin:
stat = os.stat(path)
except OSError:
continue
locator = os.path.normcase(os.path.realpath(str(path)))
if locator in marks and mark_hides(marks, locator, path):
continue
key = _physical_file_key(path, stat)
if key not in seen:
seen.add(key)
@@ -1561,13 +1551,8 @@ class AdminRoutesMixin:
if job.cancelled:
return
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
# #480: a file removed from the library is not read or linked
# again while its bytes are unchanged.
removed = await self._run_in_executor(self._removed_paths, batch)
skipped = [f for f in batch if str(f) in known or str(f) in removed]
to_read = [
f for f in batch if str(f) not in known and str(f) not in removed
]
skipped = [f for f in batch if str(f) in known]
to_read = [f for f in batch if str(f) not in known]
outcomes = [_scan_outcome(f, roots, "skipped") for f in skipped]
counts["processed"] += len(skipped)
counts["unchanged"] += len(skipped)
+3 -13
View File
@@ -23,11 +23,6 @@ from .prompts import thumbnail_url_for
from . import thumbnail_settings
from .. import model_gate
try:
from ...utils.media_folders import is_skipped_folder
except ImportError:
from utils.media_folders import is_skipped_folder
from aiohttp import web
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
@@ -1324,9 +1319,8 @@ class AutotagRoutesMixin:
def _collect_scan_files(output_path: Path):
"""Media files under ``output_path`` from a bounded, symlink-free walk.
Descends at most ``_SCAN_MAX_DEPTH`` levels, skips ``thumbnails`` and
trash folders (``utils.media_folders``) and symlinked files, and stops
after ``_SCAN_MAX_FILES`` files.
Descends at most ``_SCAN_MAX_DEPTH`` levels, skips ``thumbnails``
trees and symlinked files, and stops after ``_SCAN_MAX_FILES`` files.
Returns ``(paths, truncated)``.
"""
found = []
@@ -1336,7 +1330,7 @@ class AutotagRoutesMixin:
dirnames[:] = sorted(
name
for name in dirnames
if not is_skipped_folder(name) and depth + 1 <= _SCAN_MAX_DEPTH
if name != "thumbnails" and depth + 1 <= _SCAN_MAX_DEPTH
)
for name in sorted(filenames):
if Path(name).suffix.lower() not in _MEDIA_EXTENSIONS:
@@ -1363,10 +1357,6 @@ class AutotagRoutesMixin:
files, truncated = await self._run_in_executor(
self._collect_scan_files, output_path
)
# #501: a file removed from the library is not offered for tagging.
files = await self._run_in_executor(
self._without_removed, output_path, files
)
has_thumbnails = (output_path / "thumbnails").is_dir()
images = []
-2
View File
@@ -213,8 +213,6 @@ def public_config():
"max_image_age_days": GalleryConfig.MAX_IMAGE_AGE_DAYS,
"max_concurrent_processing": GalleryConfig.MAX_CONCURRENT_PROCESSING,
"metadata_extraction_timeout": GalleryConfig.METADATA_EXTRACTION_TIMEOUT,
# config.json gallery.monitoring.save_foreign_runs (#247)
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
},
"integrations": {
"lora_manager_enabled": bool(IntegrationConfig.LORA_MANAGER_ENABLED),
+1 -13
View File
@@ -27,11 +27,7 @@ from .history_current import (
resolve_current_candidate,
snapshot_current_output_scope,
)
from .inspection_service import (
CurrentFileChanged,
image_tags_reader,
inspect_current_file,
)
from .inspection_service import CurrentFileChanged, inspect_current_file
from .images import image_category_key
try:
@@ -782,14 +778,6 @@ class HistoryRoutesMixin:
),
own,
),
# The file's own image tags, keyed as the Tag dialog
# keys them: the normcased realpath (#457).
image_tags=image_tags_reader(
db,
str(resolved.canonical_target),
self.logger,
close=True,
),
recorded=recorded_output_lookup(
db, close=True, reference=receipt["reference"]
),
-521
View File
@@ -1,521 +0,0 @@
"""Delete selected images: remove them from the library, or trash them (#480).
``POST /prompt_manager/images/delete/preview {items | filter}`` reviews the
selection for both modes at once and answers a reviewed plan (the System ›
Maintenance contract, ``py/api/maintenance.py``): the token, the counts of
the records each file loses, the refused files with a coded reason (both
modes, and the trash-only ones), the prompts that lose their last image and
the safety backup. It writes nothing. ``items`` are Images listing identities
(at most 500); ``filter`` is "Select all N matching". Each target is matched
to its entry in the current listing (``not-listed`` otherwise), so every
later step uses the walked spelling, never the request's text.
``POST /prompt_manager/maintenance/apply {token, mode}`` runs the plan as the
``image_delete`` job (admission ``records``, not cancellable): the sources
are checked, the promised safety backup is taken, then each file is checked
again (containment and fingerprint) and
- ``mode="library"``: its records are deleted and its removal mark written
in one transaction; the file stays on disk, hidden;
- ``mode="trash"``: it is moved to the trash first (``utils/trash.py``); a
refused move leaves the file and its records alone; after a move its
records are deleted.
No path here unlinks a media file. A failed records write stops the job; the
files after it are not attempted.
"""
import hashlib
import json
import os
import stat as stat_module
from pathlib import Path
from .admin import DELETABLE_MEDIA_EXTENSIONS, _remove_thumbnail
from .duplicate_review import (
CHANGED_CONTENT,
GONE,
LINK,
OUTSIDE_SOURCE,
UNREADABLE,
file_problem,
)
from .images import _favorite_relative_key, _favorite_root_key
from .jobs import IMAGE_DELETE_GROUP_NAMES, IMAGE_DELETE_JOB_KIND, JobBusy
from .job_routes import busy_response
from .maintenance import MAINTENANCE_PREVIEW_LEASE, _fail_job, _failure, backup_note
try:
from ...database import image_removal_schema
from ...database.image_removal import mark_hides
from ...database.annotation_schema import MAX_LOCATOR_LENGTH
from ...utils import trash
from ...utils.media_folders import is_trash_folder
except ImportError:
from database import image_removal_schema
from database.image_removal import mark_hides
from database.annotation_schema import MAX_LOCATOR_LENGTH
from utils import trash
from utils.media_folders import is_trash_folder
IMAGE_DELETE = "delete_images"
MODES = ("library", "trash")
TITLES = {
"library": "Delete images (remove from the library)",
"trash": "Delete images (move to the trash)",
}
MAX_DETAIL_ROWS = 50
# The system moved the file but its place in a trash could not be shown (#499).
TRASH_UNCONFIRMED = "trash-unconfirmed"
RECORDS_FAILED_CODE = "records-failed"
# Reason codes (design §4.4) and the sentence each shows.
REASONS = {
"not-listed": "No longer listed in Images.",
"outside": "Outside the output folders.",
"link": "A link, not a file.",
"gone": "The file no longer exists.",
"unreadable": "The file can't be read.",
"thumbnail": "A generated preview, not an image.",
"not-media": "Not a media file.",
"trash-folder": "Inside a trash folder.",
"path-too-long": "The path is too long.",
"changed": "The file changed after the review.",
trash.NO_TRASH: "This drive has no trash.",
trash.NETWORK: "Network drives can't use the trash.",
trash.TRASH_IN_ROOT: "This drive's trash is inside an output folder.",
trash.LOCKED: "The file is open in another program. Close it and try again.",
trash.FAILED: "The file could not be moved to the trash.",
trash.NO_PERMISSION: (
"PromptManager isn't allowed to move this file (no permission, or it is "
"locked in Finder)."
),
trash.CLOUD_SYNC: (
"Files in iCloud Drive or a cloud-synced folder can't be moved to the "
"trash here."
),
TRASH_UNCONFIRMED: (
"Moved by macOS, but its place in the trash could not be confirmed."
),
trash.WINDOWS_RECYCLE: (
"Moving files to the Recycle Bin isn't available on Windows: files larger "
"than the bin's capacity would be deleted permanently."
),
"not-attempted": "Not attempted: the job stopped after an error.",
}
_PROBLEM_CODES = {
OUTSIDE_SOURCE: "outside",
LINK: "link",
GONE: "gone",
UNREADABLE: "unreadable",
CHANGED_CONTENT: "changed",
}
SOURCES_CHANGED = "The sources changed since the review; nothing was changed."
RECORDS_FAILED = "The image records could not be removed; the job stopped."
def lexical_problem(relative: str) -> str | None:
"""The refusal code a root-relative POSIX path earns before any file access."""
parts = relative.split("/")
if "thumbnails" in parts[:-1]:
return "thumbnail"
if any(is_trash_folder(part) for part in parts[:-1]):
return "trash-folder"
if os.path.splitext(parts[-1])[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
return "not-media"
return None
def file_check(path, root, relative, *, expected=None):
"""``(code, lstat)``: why *path* may not be deleted, or ``(None, lstat)``.
Text first (inside the root, not a preview or trash folder, a media
file), then ``duplicate_review.file_problem``: a regular file, not a
link, whose resolved path is still inside the resolved root. With
*expected* (``{size, mtime_ns, inode}`` from the review) the file must
still be those bytes. Every handle is closed when this returns.
"""
code = lexical_problem(relative)
if code:
return code, None
try:
info = os.lstat(path)
except FileNotFoundError:
return "gone", None
except OSError:
return "unreadable", None
if getattr(info, "st_file_attributes", 0) & getattr(
stat_module, "FILE_ATTRIBUTE_REPARSE_POINT", 0
):
return "link", info
if expected is None:
problem = file_problem(path, root, None, info.st_size, hash_check=False)
else:
problem = file_problem(
path,
root,
None,
expected["size"],
hash_check=False,
mtime_ns=expected["mtime_ns"],
inode=expected["inode"],
)
if problem:
return _PROBLEM_CODES.get(problem, "outside"), info
if len(os.path.normcase(os.path.realpath(path))) > MAX_LOCATOR_LENGTH:
return "path-too-long", info
return None, info
def _row(target, code):
return {
"path": target["rel"],
"root_label": target["root_label"],
"code": code,
"detail": REASONS[code],
}
def _summary(rows):
counts = {}
for row in rows:
counts[row["code"]] = counts.get(row["code"], 0) + 1
return {"count": len(rows), "counts": counts, "rows": rows[:MAX_DETAIL_ROWS]}
def _fingerprint(targets) -> str:
material = [
(t["root_index"], t["rel"], t["size"], t["mtime_ns"], t["inode"])
for t in targets
]
return hashlib.sha256(json.dumps(material).encode("utf-8")).hexdigest()
class ImageDeleteMixin:
"""The Images Delete… review and its apply job (#480)."""
def _register_image_delete_routes(self, routes):
# A fixed path beside the dynamic /images/{image_id} routes: aiohttp
# (3.10+) looks fixed paths up first (see image_categories.py).
@routes.post("/prompt_manager/images/delete/preview")
async def preview_image_delete_route(request):
return await self.preview_image_delete(request)
# ── review ────────────────────────────────────────────────────────
async def preview_image_delete(self, request):
"""Review the selection under the maintenance preview lease."""
try:
async with self._jobs.lease(MAINTENANCE_PREVIEW_LEASE):
return await self._preview_image_delete(request)
except JobBusy as busy:
return busy_response(busy)
async def _preview_image_delete(self, request):
_data, targets, roots, error = await self._category_request(
request, frozenset()
)
if error is not None:
return error
db = self.db
identity = self._database_identity(db)
try:
files = {}
for index in dict.fromkeys(t[0] for t in targets):
files[index] = await self._get_gallery_files(roots[index])
review = await self._run_in_executor(
self._review_image_delete, db, roots, targets, files
)
except Exception:
self.logger.exception("Image delete review failed")
return _failure("The review failed. Check server logs for details.", 500)
return await self._plan_response(
db,
identity,
IMAGE_DELETE,
_fingerprint(review["targets"]),
review["public"],
{"targets": review["targets"]},
)
def _review_image_delete(self, db, roots, targets, files):
"""Every target checked now (blocking); ``{targets, public}``."""
listings, seen = {}, set()
acted, refused, trash_refused = [], [], []
spellings = self._source_spellings()
root_texts = [str(root) for root in roots]
for index, _scope, relative in targets:
root = roots[index]
if index not in listings:
listings[index] = self._favorite_listing(root, files[index])
spelled = listings[index].get(_favorite_relative_key(relative))
target = {
"root_index": index,
"root_scope": _scope,
"root": str(root),
"root_label": self._public_path(root),
"rel": spelled or relative,
}
if spelled is None:
refused.append(_row(target, "not-listed"))
continue
absolute = os.path.join(str(root), *spelled.split("/"))
code, info = file_check(absolute, str(root), spelled)
if code:
refused.append(_row(target, code))
continue
identity = (info.st_dev, info.st_ino)
if identity in seen:
continue # one physical file named twice (nested roots, hardlinks)
seen.add(identity)
target.update(self._removal_of(absolute, info, spellings))
target["trash"] = trash.trash_available(absolute, root_texts)
if target["trash"]:
trash_refused.append(_row(target, target["trash"]))
acted.append(target)
counted = db.count_removal_records([t["removal"] for t in acted])
library_count = len(acted)
public = {
"operation": IMAGE_DELETE,
"scope": {"count": library_count, "label": "images"},
"library": {"count": library_count},
"trash": {"count": library_count - len(trash_refused)},
"protected": {"count": len(refused), "reasons": []},
"refused": _summary(refused),
"trash_refused": _summary(trash_refused),
"records": counted["records"],
"orphaned_prompts": counted["orphaned_prompts"],
"media": {"deletes_files": False, "label": "Chosen in the review"},
"details": [],
}
return {"targets": acted, "public": public}
@staticmethod
def _removal_of(absolute, info, spellings):
favorites = [
(_favorite_root_key(root), _favorite_relative_key(relative))
for root, relative in spellings.favorites(absolute).items()
]
return {
"abs": absolute,
"size": info.st_size,
"mtime_ns": info.st_mtime_ns,
"inode": [info.st_dev, info.st_ino],
"removal": {
"spellings": list(spellings.of(absolute).values()),
"locator": os.path.normcase(os.path.realpath(absolute)),
"favorites": favorites,
"category": spellings.category_key(absolute),
},
}
# ── apply ─────────────────────────────────────────────────────────
def _start_image_delete(self, db, plan, mode, backup_on):
"""Register the delete job for a reviewed plan (on the loop)."""
return self._jobs.start(
IMAGE_DELETE_JOB_KIND,
TITLES[mode],
admission="records",
cancellable=False,
scope={"operation": IMAGE_DELETE, "mode": mode},
groups=IMAGE_DELETE_GROUP_NAMES,
runner=lambda job: self._run_image_delete(job, db, plan, mode, backup_on),
)
async def _run_image_delete(self, job, db, plan, mode, backup_on):
targets = plan.payload["targets"]
unchanged = await self._run_in_executor(self._sources_unchanged, targets)
if not unchanged:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return
try:
await self._delete_images(job, db, targets, mode, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
async def _delete_images(self, job, db, targets, mode, backup):
job.update(total=len(targets), status_line=TITLES[mode])
roots = [str(root) for root in self._get_all_output_dirs()]
done_group = "trashed" if mode == "trash" else "removed"
changed, failed = 0, None
for done, target in enumerate(targets, start=1):
if failed:
self._record_delete(job, "not_changed", target, "not-attempted")
continue
group, code, label = await self._run_in_executor(
self._delete_one_image, db, target, mode, roots
)
if group == "errors":
failed = code or RECORDS_FAILED_CODE
text = REASONS[code] if code else RECORDS_FAILED
job.record(
"errors",
{
"path": target["rel"],
"root": target["root_label"],
"root_index": target["root_index"],
"root_scope": target["root_scope"],
"detail": text,
},
f"! {target['root_label']}/{target['rel']} · {text}",
)
elif group == done_group:
changed += 1
detail = (
f"Moved to the {label}" if label else "Removed from the library"
)
self._record_delete(job, group, target, None, detail)
else:
self._record_delete(job, group, target, code)
job.update(processed=done, progress=int(done / len(targets) * 100))
if changed:
self.invalidate_gallery_cache()
verb = "Moved" if mode == "trash" else "Removed"
where = "to the trash" if mode == "trash" else "from the library"
line = f"{verb} {changed:,} of {len(targets):,} images {where}"
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
if failed:
# "records-failed" names every stop at an errors row (the page's
# "Stopped after N of M" toast); the row says which error it was.
job.scope["failure"] = RECORDS_FAILED_CODE
job.publish({"type": "error", "code": failed, "message": line})
job.finish("failed")
@staticmethod
def _record_delete(job, group, target, code, detail=None):
text = detail or REASONS[code]
row = {
"path": target["rel"],
"root": target["root_label"],
"root_index": target["root_index"],
"root_scope": target["root_scope"],
"code": code,
"detail": text,
}
mark = {"removed": "-", "trashed": "-"}.get(group, "=")
job.record(
group, row, f"{mark} {target['root_label']}/{target['rel']} · {text}"
)
def _delete_one_image(self, db, target, mode, roots):
"""``(group, code, trash label)`` for one reviewed file (blocking)."""
code, _info = file_check(
target["abs"],
target["root"],
target["rel"],
expected={
"size": target["size"],
"mtime_ns": target["mtime_ns"],
"inode": target["inode"],
},
)
if code:
return "not_changed", code, None
label = None
if mode == "trash":
try:
label = trash.move_to_trash(target["abs"], roots)
except trash.TrashRefused as refused:
code = refused.code if refused.code in REASONS else trash.FAILED
self.logger.info(
"Not moved to the trash (%s): %s %s",
code,
target["rel"],
refused.detail,
)
return "not_changed", code, None
except trash.TrashUnconfirmed as unconfirmed:
# The file left its folder: keep its records, stop the job.
self.logger.warning(
"Trash move not confirmed: %s %s", target["rel"], unconfirmed.detail
)
return "errors", TRASH_UNCONFIRMED, None
mark = None
else:
mark = (target["size"], target["mtime_ns"])
try:
db.remove_image_records(target["removal"], mark=mark)
except Exception:
self.logger.exception("Removing an image's records failed")
return "errors", None, label
_remove_thumbnail(Path(target["root"]), Path(target["rel"]), self.logger)
return ("trashed" if mode == "trash" else "removed"), None, label
# ── the workspace listing ─────────────────────────────────────────
def _removal_marks(self) -> dict:
"""The removal marks, cached until a mark changes or the database does.
A listing page reads no table while nothing changed: the cache follows
this process's marks generation and the database identity (a restore).
"""
key = (self._database_identity(self.db), image_removal_schema.generation())
cached = getattr(self, "_marks_cache", None)
if cached is None or cached[0] != key:
cached = (key, self.db.removal_marks())
self._marks_cache = cached
return cached[1]
def _hidden_entries(self, listings) -> set:
"""Normalized paths of listed files a removal mark hides (blocking).
Only marked locators are looked at, and only those files are
``lstat``'ed: a mark hides a file while its size and ``mtime_ns`` are
still the ones it was removed with.
"""
marks = self._removal_marks()
if not marks:
return set()
located = self._workspace_locators(listings)
return {
entry.normalized
for locator, entry in ((locator, located.get(locator)) for locator in marks)
if entry is not None and mark_hides(marks, locator, entry.path)
}
def _removed_paths(self, paths) -> set:
"""The absolute paths among *paths* a removal mark hides (blocking)."""
marks = self._removal_marks()
if not marks:
return set()
return {
str(path)
for path in paths
if mark_hides(marks, os.path.normcase(os.path.realpath(str(path))), path)
}
def _removal_check_under(self, root):
"""``hidden(path) -> bool`` for files a walk of *root* found (blocking).
A walk never follows a link below *root*, so a file's locator is
``normcase(join(realpath(root), relative))``: no ``realpath`` per
file, and an ``lstat`` only for a marked one. Without marks it
answers False at once.
"""
marks = self._removal_marks()
if not marks:
return lambda _path: False
root = os.fspath(root)
real = os.path.realpath(root)
def hidden(path):
relative = os.path.relpath(os.fspath(path), root)
locator = os.path.normcase(os.path.join(real, relative))
return mark_hides(marks, locator, path)
return hidden
def _without_removed(self, root, paths) -> list:
"""*paths* found by a walk of *root*, less the hidden ones (blocking)."""
removed = self._removal_check_under(root)
return [path for path in paths if not removed(path)]
def parse_mode(value):
"""The apply's ``mode``, or None when it is not one of MODES."""
return value if isinstance(value, str) and value in MODES else None
+1 -5
View File
@@ -488,11 +488,7 @@ class ImageTagJobsMixin:
_still_targets, self, index, root, files, seen
)
)
# #480: a file removed from the library is never tagged by a batch.
hidden = await self._run_in_executor(
self._removed_paths, [target.path for target in found]
)
return [target for target in found if target.path not in hidden]
return found
async def _start_autotag(
self, *, model_type, existing, options, keep_in_memory, title, owner_kind
+44 -93
View File
@@ -13,7 +13,6 @@ import re
import stat
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
import sys
import threading
import time as _time
import urllib.parse
from bisect import bisect_left, bisect_right, insort
@@ -55,7 +54,6 @@ from .inspection_service import (
IMAGE_EXTENSIONS as _IMAGE_EXTENSIONS,
SERVABLE_EXTENSIONS,
VIDEO_EXTENSIONS,
image_tags_reader,
inspect_current_file,
)
@@ -66,7 +64,6 @@ try:
from ...database.models import normalize_image_path
from ...database.run_attribution import recorded_output_lookup
from ...utils.card_seed import card_seed, header_params, parsed_positive
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
from ...utils.parallel import map_parallel
from ...utils.json_strict import dumps_strict
from ...utils.inspection_response import (
@@ -84,7 +81,6 @@ try:
reveal_executor,
reveal_in_file_manager,
)
from ...utils.workspace_keys import gallery_root_scope, workspace_keys_for
except ImportError:
from database.image_collections import CollectionMissing
from database.image_favorites import MAX_KEY_LENGTH
@@ -92,7 +88,6 @@ except ImportError:
from database.models import normalize_image_path
from database.run_attribution import recorded_output_lookup
from utils.card_seed import card_seed, header_params, parsed_positive
from utils.media_folders import in_skipped_folder, is_skipped_folder
from utils.parallel import map_parallel
from utils.json_strict import dumps_strict
from utils.inspection_response import (
@@ -110,7 +105,6 @@ except ImportError:
reveal_executor,
reveal_in_file_manager,
)
from utils.workspace_keys import gallery_root_scope, workspace_keys_for
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
@@ -524,8 +518,7 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
_READ_CHUNK = 64 * 1024
# Guards the first load of an API's workspace keys (#430, _workspace_keys).
_WORKSPACE_KEYS_LOCK = threading.Lock()
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
@@ -625,37 +618,15 @@ def _rated_key(entry: _Listed, rank: int) -> tuple:
return (-rank, *_workspace_key(entry, "rating"))
# Bump whenever the cursor's key tuple changes shape: a cursor now outlives a
# restart and an upgrade (#430), so an old shape must be refused, not parsed.
_CURSOR_VERSION = 2
_CURSOR_STALE = "The image collection changed; refresh the gallery"
class CursorStale(ValueError):
"""A well-formed cursor this listing cannot verify (#430).
Another key, another root list or other filters: the page refreshes its
collection instead of retrying the same cursor.
"""
def _cursor_mac(secret: bytes, scope: str, payload: bytes) -> bytes:
# The listing scope (it hashes every absolute root) is bound in the MAC
# only, never sent: the scope is 64 hex, so the "\n" join is unambiguous.
return hmac.new(secret, scope.encode() + b"\n" + payload, hashlib.sha256).digest()
def _workspace_cursor(key: tuple, scope: str, secret: bytes) -> str:
"""The signed keyset cursor of *key*; *secret* is the API's cursor key."""
def _workspace_cursor(key: tuple, scope: str) -> str:
payload = json.dumps(
{"v": _CURSOR_VERSION, "key": key}, separators=(",", ":"), ensure_ascii=False
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
).encode()
signature = _cursor_mac(secret, scope, payload)
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) -> tuple:
"""The keyset key of *token*; CursorStale when it is not this listing's."""
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
try:
if len(token) > 4096:
raise ValueError()
@@ -663,14 +634,13 @@ def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) ->
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = raw[:32], raw[32:]
if len(signature) != 32 or not payload:
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise ValueError()
if not hmac.compare_digest(signature, _cursor_mac(secret, scope, payload)):
raise CursorStale(_CURSOR_STALE)
data = json.loads(payload)
key = data["key"]
if data["v"] != _CURSOR_VERSION or not isinstance(key, list):
raise CursorStale(_CURSOR_STALE)
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
raise ValueError()
types = (
(str, str, int, (float, int))
if order in ("name", "name-desc")
@@ -684,8 +654,6 @@ def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) ->
):
raise ValueError()
return tuple(key)
except CursorStale:
raise
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
@@ -917,8 +885,7 @@ def _name_suffix(name):
def _walk_media(output_path, max_files):
"""Yield the ``os.DirEntry`` of each media file under *output_path*.
Symlinks are never followed, ``thumbnails/`` and trash folders are
skipped (``utils.media_folders``, #501), descent stops
Symlinks are never followed, ``thumbnails/`` is skipped, descent stops
at MAX_SCAN_DEPTH and at most *max_files* entries are produced. The order
is ``os.walk``'s (top-down, subfolders in ``os.scandir`` order, files by
name), which the gallery's ties in mtime keep. Each entry's type comes
@@ -930,6 +897,8 @@ def _walk_media(output_path, max_files):
produced = 0
while stack:
top, rel_parts = stack.pop()
if "thumbnails" in rel_parts:
continue
dirs, files = [], []
try:
with os.scandir(top) as entries:
@@ -953,7 +922,7 @@ def _walk_media(output_path, max_files):
if len(rel_parts) >= MAX_SCAN_DEPTH:
continue
for entry in reversed(dirs):
if not entry.is_symlink() and not is_skipped_folder(entry.name):
if not entry.is_symlink():
stack.append((entry.path, rel_parts + (entry.name,)))
@@ -977,7 +946,7 @@ def _settled_entry(root, relative):
*folders, name = relative.split(os.sep)
if _name_suffix(name).lower() not in MEDIA_EXTENSIONS:
return None
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
if "thumbnails" in folders or len(folders) > MAX_SCAN_DEPTH:
return None
folder = os.fspath(root)
for part in ("", *folders):
@@ -1369,27 +1338,13 @@ def _in_subfolder(rel_dir, subfolder):
class ImageRoutesMixin:
"""Mixin providing image and gallery-related API endpoints."""
def _workspace_keys(self):
"""This API's scope and cursor keys, loaded once (#430).
They derive from the secret persisted beside the database, so a restart
keeps every scope and cursor an open page holds. The first call reads
(or writes) one small file; later calls return the cached keys, which
a live library move or restore does not change before a restart.
"""
keys = self.__dict__.get("_workspace_keys_cache")
if keys is None:
with _WORKSPACE_KEYS_LOCK:
keys = self.__dict__.get("_workspace_keys_cache")
if keys is None:
db = getattr(self, "db", None)
model = getattr(db, "model", None)
keys = workspace_keys_for(getattr(model, "db_path", None))
self.__dict__["_workspace_keys_cache"] = keys
return keys
def _gallery_root_scope(self, root: Path, index: int) -> str:
return gallery_root_scope(self._workspace_keys(), str(_canonical(root)), index)
payload = json.dumps(
["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
def _gallery_scope_error(
self, query: Mapping, roots: list[Path]
@@ -2053,8 +2008,6 @@ class ImageRoutesMixin:
limit, _offset = parse_page_params(request.query, default_limit=100)
except ValueError:
return bad_request("limit and offset must be integers")
# #430: the first use may create the key file; never on the event loop.
cursor_secret = (await self._run_in_executor(self._workspace_keys)).cursor
try:
root_index = _workspace_root(request.query, output_dirs)
filters = _workspace_filters(request.query)
@@ -2078,15 +2031,7 @@ class ImageRoutesMixin:
).hexdigest()
token = request.query.get("cursor")
after = (
_workspace_cursor_key(token, scope, filters["order"], cursor_secret)
if token
else None
)
except CursorStale as exc:
return web.json_response(
{"success": False, "code": "cursor-stale", "error": str(exc)},
status=400,
headers={"Cache-Control": "no-store"},
_workspace_cursor_key(token, scope, filters["order"]) if token else None
)
except ValueError as exc:
return bad_request(str(exc))
@@ -2451,10 +2396,6 @@ class ImageRoutesMixin:
def key(entry):
return _workspace_key(entry, order)
# #480: files removed from the library stay on disk, hidden here.
hidden = self._hidden_entries(listings)
if hidden:
entries = [entry for entry in entries if entry.normalized not in hidden]
return (
entries,
key,
@@ -2531,11 +2472,7 @@ class ImageRoutesMixin:
positive_count = _COUNT_OMITTED
has_more = len(page) > limit
page = page[:limit]
next_cursor = (
_workspace_cursor(key(page[-1]), scope, self._workspace_keys().cursor)
if has_more
else None
)
next_cursor = _workspace_cursor(key(page[-1]), scope) if has_more else None
return (
[(entry.path, entry.mtime, entry.root, entry.index) for entry in page],
total,
@@ -3076,9 +3013,7 @@ class ImageRoutesMixin:
no symlinks), each preview folder is listed once, and at most
MAX_THUMBNAIL_FILES targets are queued per batch (a job plans again
after each batch); ``remaining`` counts the targets beyond that cap.
Sources in *exclude* (worked on by an earlier batch) are left out, and
so are files removed from the library (#501: a removal mark hides
them, and their previews went with their records).
Sources in *exclude* (worked on by an earlier batch) are left out.
``stop()`` true ends the walk before the next file.
"""
settings = writer_settings(settings)
@@ -3087,14 +3022,11 @@ class ImageRoutesMixin:
plan = {key: 0 for key in _PLAN_COUNTS}
plan.update(targets=[], fresh_files=[])
folders = {}
removed = self._removal_check_under(output_path)
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
if stop is not None and stop():
break
if exclude and src in exclude:
continue
if removed(src):
continue
rel_no_ext = src.relative_to(output_path).with_suffix("")
stem = f"{rel_no_ext.name}{thumbnail_settings.THUMB_SUFFIX}"
dst = thumbnails_dir / rel_no_ext.parent / (stem + settings.extension)
@@ -3973,9 +3905,28 @@ class ImageRoutesMixin:
)
def _inspection_image_tags(self, root: Path, parts: tuple) -> Callable:
"""The inspector's ``image_tags`` reader for one rooted file (S1)."""
"""The inspector's ``image_tags`` reader for one rooted file (S1).
The returned function takes the inspection's own stable stat and
answers ``current`` only for a byte record verified at exactly that
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
"""
locator = os.path.normcase(os.path.join(os.path.realpath(root), *parts))
return image_tags_reader(self.db, locator, self.logger)
def read(size: int, mtime_ns: int) -> dict:
try:
found = self.db.current_image_tags({locator: (size, mtime_ns)})
except Exception:
self.logger.warning(
"Inspection image tags could not be read", exc_info=True
)
return {"state": "unavailable", "tags": []}
tags = found.get(locator)
if tags is None:
return {"state": "none", "tags": []}
return {"state": "current", "tags": list(tags)}
return read
def _read_own_category(self, sources, path):
"""One file's own category, None, or OWN_CATEGORY_UNREAD (blocking).
-29
View File
@@ -93,35 +93,6 @@ def _in_host_temp(path: Path) -> bool:
return False
def image_tags_reader(
db: Any, locator: str, logger: Any, *, close: bool = False
) -> Callable[[int, int], dict]:
"""The inspector's ``image_tags`` reader for one file *locator* (S1).
The returned function takes the inspection's own stable stat and answers
``current`` only for a byte record verified at exactly that
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
``close`` closes the calling thread's connection afterwards, for routes
that close theirs after reading (History).
"""
def read(size: int, mtime_ns: int) -> dict:
try:
found = db.current_image_tags({locator: (size, mtime_ns)})
except Exception:
logger.warning("Inspection image tags could not be read", exc_info=True)
return {"state": "unavailable", "tags": []}
finally:
if close:
db.model.close()
tags = found.get(locator)
if tags is None:
return {"state": "none", "tags": []}
return {"state": "current", "tags": list(tags)}
return read
def inspect_current_file(
path: Path,
*,
-5
View File
@@ -123,11 +123,6 @@ MODEL_DOWNLOAD_JOB_KIND = "model_download"
DUPLICATES_JOB_KIND = "duplicates"
DUPLICATE_GROUP_NAMES = ("groups_found",)
DUPLICATE_CLEANUP_GROUP_NAMES = ("removed", "kept", "errors")
# The workspace Library's Delete… (#443, py/api/library_delete.py): a
# ``records`` hold around its safety backup and write.
PROMPT_DELETE_JOB_KIND = "prompt_delete"
IMAGE_DELETE_JOB_KIND = "image_delete" # #480
IMAGE_DELETE_GROUP_NAMES = ("trashed", "removed", "not_changed", "errors")
# The System health check (py/api/system.py): a read-only ``read`` job with
# no file outcomes, so it may run beside a scan.
HEALTH_JOB_KIND = "health"
+2 -62
View File
@@ -21,11 +21,9 @@ from .pairs import pair_response
try:
from ...database.library import ARCHIVE_SCOPES
from ...database.library_commands import LibraryCommandError
from ...database.library_folders import folder_prefixes, normalize_folder
except ImportError:
from database.library import ARCHIVE_SCOPES
from database.library_commands import LibraryCommandError
from database.library_folders import folder_prefixes, normalize_folder
_SECRET = os.urandom(32)
@@ -157,13 +155,6 @@ def _archive_scope(request: web.Request) -> str:
return value
def _shelf(request: web.Request) -> str:
value = request.query.get("shelf", "active")
if value not in ("active", "archived"):
raise ValueError("shelf must be active or archived")
return value
def _missing() -> web.Response:
return web.json_response(
{"success": False, "error": "Positive prompt not found"}, status=404
@@ -292,87 +283,36 @@ class LibraryRoutesMixin:
outputs = list(self._get_all_output_dirs())
return self._media_roots(outputs), len(outputs)
def _library_root_spellings(self) -> list[str]:
"""Every spelling of each output root (#444 folder filter): the
validated, resolved root and each configured or detected path that
resolves to it, so images saved through a symlinked root match."""
from ..config import GalleryConfig
roots = [str(root) for root in self._get_all_output_dirs()]
candidates = [
*GalleryConfig.MONITORING_DIRECTORIES,
self._find_comfyui_output_dir(),
*self._comfyui_output_dir_list(),
]
spellings = []
for root in roots:
for value in (root, *candidates):
if not value or not isinstance(value, (str, os.PathLike)):
continue
value = str(value)
same = value == root or os.path.normcase(
os.path.realpath(value)
) == os.path.normcase(root)
if same and value not in spellings:
spellings.append(value)
return spellings
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
include = request.query.get("include", "")
if include not in ("", "folders"):
raise ValueError("include must be empty or 'folders'")
if include:
# Folders are read only when Refine opens (a walk of linked paths).
roots = await self._run_in_executor(self._library_root_spellings)
result = await self._run_in_executor(self.db.get_library_options, roots)
else:
result = await self._run_in_executor(self.db.get_library_options)
result = await self._run_in_executor(self.db.get_library_options)
return web.json_response({"success": True, **result})
@pair_response
async def list_library(self, request: web.Request) -> web.Response:
folder = normalize_folder(request.query.get("folder", ""))
options = {
"text": request.query.get("text", ""),
"category": request.query.get("category"),
"tags": sorted(set(request.query.getall("tag", []))),
"tag_mode": request.query.get("tag_mode", "all"),
"sort": request.query.get("sort", "newest"),
# #443: which shelf; part of the cursor scope, so a cursor never
# crosses shelves. Not the variant ``archive`` scope of /{id}.
"shelf": _shelf(request),
}
limit = _page_limit(request)
cursor = request.query.get("cursor")
roots = (
await self._run_in_executor(self._library_root_spellings) if folder else []
)
prefixes = folder_prefixes(roots, folder) if folder else None
def read() -> tuple[dict, dict]:
with self.db.library_snapshot(include_ceiling=True) as snapshot:
conn, epoch, ceiling = snapshot
# The folder and the roots it was resolved under join the scope.
scope = _scope(
{
"kind": "parents",
"projection": 2,
"epoch": epoch,
**options,
**({"folder": folder, "roots": roots} if folder else {}),
}
{"kind": "parents", "projection": 2, "epoch": epoch, **options}
)
result = self.db._list_library(
conn,
epoch,
ceiling,
**options,
folder_prefixes=prefixes,
limit=limit,
after=_decode(cursor, scope),
)
result["folder"] = folder
records = self.db.get_library_previews(
[parent["id"] for parent in result["items"]]
)
-297
View File
@@ -1,297 +0,0 @@
"""Delete or archive selected Library prompts, and restore archived ones (#443).
The workspace Prompt library's Delete… (D152). The legacy ``POST
/prompt_manager/bulk/delete`` and ``DELETE /prompt_manager/delete/{id}`` are
separate and unchanged.
- ``POST /prompt_manager/library/delete/preview {prompt_ids, expected_epoch}``
classifies the selection (reads only, under a lease): the ids to ``delete``
(in no saved pair), to ``archive`` (paired), the ``protected`` ones (tagged
``__protected__``, kept), counts of ``already_archived`` and ``not_found``,
the ``image_links`` a delete removes, the safety ``backup`` policy and,
when a backup would be taken, the ``disk`` it needs.
- ``POST /prompt_manager/library/delete {archive_ids, delete_ids,
expected_epoch, backup}`` executes that review under a ``records`` hold
(refused 409 ``busy`` beside a scan, restore, maintenance, move or
Optimize). When a delete remains and a backup was promised (the reviewed
``backup`` was "on" or the policy is on now) it checks the disk (507
``insufficient-space``) and takes a verified safety backup (500
``backup-failed``); then one transaction deletes the reviewed deletes whose
fingerprint is unchanged since the backup and archives the paired ones.
- ``POST /prompt_manager/library/restore {prompt_ids, expected_epoch}``
removes the archive mark (under a lease).
A changed storage epoch answers 409 ``storage-changed``; nothing is written.
"""
import sqlite3
from aiohttp import web
try:
from ...database.library_commands import LibraryCommandError
from ...database.safety_backup import SafetyBackupFailed
except ImportError:
from database.library_commands import LibraryCommandError
from database.safety_backup import SafetyBackupFailed
from . import maintenance as _maintenance
from .admin import _prune_safety_backups
from .job_routes import busy_response
from .jobs import PROMPT_DELETE_JOB_KIND, JobBusy
from .prompts import parse_bulk_prompt_ids
PREVIEW_URL = "/prompt_manager/library/delete/preview"
DELETE_URL = "/prompt_manager/library/delete"
RESTORE_URL = "/prompt_manager/library/restore"
DELETE_TITLE = "Delete prompts (Library)"
_SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"})
_DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"})
# #482: Delete permanently, with its digests; both or neither (a #443 body).
_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"})
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
def _failure(status, code, error, **extra):
return web.json_response(
{"success": False, "code": code, "error": error, **extra}, status=status
)
def _invalid(message):
return _failure(400, "invalid-request", message)
def _storage_changed(exc):
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
async def _body(request, keys, optional=frozenset()):
"""The JSON object with exactly ``keys`` (plus all or none of ``optional``)."""
try:
data = await request.json()
except ValueError:
raise ValueError("Expected a JSON object") from None
if not isinstance(data, dict):
raise ValueError("Expected a JSON object")
extra = set(data) - keys
if not keys <= set(data) or (extra and extra != optional):
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
return data
def _paired_delete(data, archive_ids):
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
if "delete_paired_ids" not in data:
return {}
ids = _reviewed_ids(data["delete_paired_ids"], "delete_paired_ids")
digests = data["paired_digests"]
if not isinstance(digests, dict):
raise ValueError("paired_digests must map prompt ids to digests")
if not set(ids) <= set(archive_ids):
raise ValueError("delete_paired_ids must be reviewed archive ids")
chosen = {}
for pid in ids:
digest = digests.get(str(pid))
if not isinstance(digest, str) or len(digest) != 64:
raise ValueError("Every delete_paired_ids entry needs its review digest")
chosen[pid] = digest
return chosen
def _reviewed_ids(value, name):
"""A reviewed id list: possibly empty, else as ``parse_bulk_prompt_ids``."""
if value == []:
return []
try:
return parse_bulk_prompt_ids(value)
except ValueError as exc:
raise ValueError(str(exc).replace("prompt_ids", name)) from None
class LibraryDeleteRoutesMixin:
"""Mixin providing the workspace Library's delete, archive and restore."""
def _register_library_delete_routes(self, routes):
routes.post(PREVIEW_URL)(self.preview_library_delete)
routes.post(DELETE_URL)(self.delete_library_prompts)
routes.post(RESTORE_URL)(self.restore_library_prompts)
async def preview_library_delete(self, request):
try:
data = await _body(request, _SELECTION_KEYS)
ids = parse_bulk_prompt_ids(data["prompt_ids"])
except ValueError as exc:
return _invalid(str(exc))
db = self.db
policy = self._safety_backup_policy()
try:
async with self._jobs.lease("Library delete review"):
review = await self._run_in_executor(
db.classify_library_delete,
ids,
expected_epoch=data["expected_epoch"],
)
disk = None
if (review["delete"] or review["deletable"]) and policy == "on":
public, short, _size = await self._run_in_executor(
_maintenance.disk_plan,
db.model.db_path,
backup=True,
vacuum=False,
)
disk = {
**public,
"message": (
None
if short is None
else _maintenance.short_space_message(short)
),
}
except JobBusy as busy:
return busy_response(busy)
except LibraryCommandError as exc:
return _storage_changed(exc)
except ValueError as exc:
return _invalid(str(exc))
except (OSError, sqlite3.Error):
self.logger.exception("Library delete review failed")
return _failure(500, "review-failed", "The delete could not be reviewed")
return web.json_response(
{
"success": True,
"library_epoch": review["library_epoch"],
"delete": review["delete"],
"archive": review["archive"],
"protected": review["protected"],
"already_archived": len(review["already_archived"]),
"not_found": len(review["not_found"]),
"image_links": review["image_links"],
# #482: the archive ids Delete permanently may delete with pairs.
"deletable": review["deletable"],
"review_digests": {
str(pid): digest for pid, digest in review["review_digests"].items()
},
"paired_counts": review["paired_counts"],
"paired_image_links": review["paired_image_links"],
"backup": policy,
"disk": disk,
}
)
async def delete_library_prompts(self, request):
try:
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS)
archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids")
delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids")
if not archive_ids and not delete_ids:
raise ValueError("Nothing to delete or archive")
if len(archive_ids) + len(delete_ids) > 1000:
raise ValueError("At most 1000 prompts can be changed at once")
if set(archive_ids) & set(delete_ids):
raise ValueError("A prompt cannot be both archived and deleted")
if data["backup"] not in ("on", "off"):
raise ValueError("backup must be on or off")
paired = _paired_delete(data, archive_ids)
except ValueError as exc:
return _invalid(str(exc))
busy = self._jobs.blocker(PROMPT_DELETE_JOB_KIND, admission="records")
if busy is not None:
return busy_response(JobBusy(busy))
try:
async with self._jobs.hold(
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
) as job:
response = await self._held_library_delete(
self.db, data, archive_ids, delete_ids, paired
)
if response.status >= 400:
job.finish("failed")
return response
except JobBusy as busy:
return busy_response(busy)
async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired):
epoch = data["expected_epoch"]
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
backup = None
try:
candidates = None
if delete_ids or paired:
review = await self._run_in_executor(
db.classify_library_delete,
[*delete_ids, *paired],
expected_epoch=epoch,
)
candidates = review["fingerprints"]
# #482: a permanent delete of a prompt in a saved pair is backed up too.
if (candidates or review["deletable"]) and promised:
_disk, short, _size = await self._run_in_executor(
_maintenance.disk_plan,
db.model.db_path,
backup=True,
vacuum=False,
)
if short is not None:
return _failure(
507,
"insufficient-space",
_maintenance.short_space_message(short),
)
backup = await self._run_in_executor(
_maintenance.take_safety_backup, db.model
)
result = await self._run_in_executor(
db.archive_or_delete_library_prompts,
archive_ids,
delete_ids,
expected_epoch=epoch,
candidates=candidates if candidates is not None else {},
delete_paired=paired,
)
except LibraryCommandError as exc:
return _storage_changed(exc)
except SafetyBackupFailed as error:
self.logger.error("Safety backup before a Library delete failed")
return _failure(
500, "backup-failed", _maintenance.backup_failed_message(error)
)
except ValueError as exc:
return _invalid(str(exc))
except (OSError, sqlite3.Error):
self.logger.exception("Library delete failed")
return _failure(500, "delete-failed", "The prompts could not be changed")
finally:
if backup is not None:
await self._prune_after_delete(db)
return web.json_response({"success": True, **result, "backup": backup})
async def _prune_after_delete(self, db):
try:
await self._run_in_executor(_prune_safety_backups, db.model.db_path)
except Exception: # pruning old copies never fails the delete
self.logger.exception("Could not prune old safety backups")
async def restore_library_prompts(self, request):
try:
data = await _body(request, _SELECTION_KEYS)
ids = parse_bulk_prompt_ids(data["prompt_ids"])
except ValueError as exc:
return _invalid(str(exc))
try:
async with self._jobs.lease("Library restore"):
result = await self._run_in_executor(
self.db.restore_library_prompts,
ids,
expected_epoch=data["expected_epoch"],
)
except JobBusy as busy:
return busy_response(busy)
except LibraryCommandError as exc:
return _storage_changed(exc)
except ValueError as exc:
return _invalid(str(exc))
except (OSError, sqlite3.Error):
self.logger.exception("Library restore failed")
return _failure(500, "restore-failed", "The prompts could not be restored")
return web.json_response({"success": True, **result})
+4 -21
View File
@@ -281,7 +281,7 @@ def short_space_message(short):
)
def _changed_line(operation, count, marks=0):
def _changed_line(operation, count):
if operation == "optimize":
return "Optimized the database"
if operation == "duplicate_prompts":
@@ -291,11 +291,7 @@ def _changed_line(operation, count, marks=0):
noun = "unused prompt component" if count == 1 else "unused prompt components"
return f"Removed {count:,} {noun}"
noun = "link to a missing file" if count == 1 else "links to missing files"
line = f"Removed {count:,} {noun}"
if marks:
gone = "removed image" if marks == 1 else "removed images"
line += f"; forgot {marks:,} {gone} whose files are gone or changed"
return line
return f"Removed {count:,} {noun}"
def take_safety_backup(model, label="backup"):
@@ -423,7 +419,6 @@ class MaintenanceRoutesMixin:
draft.public(),
{
"targets": draft.targets,
"marks": draft.marks,
"roots": roots,
"protected": draft.protected["count"],
"rows": draft.scope["count"],
@@ -493,9 +488,7 @@ class MaintenanceRoutesMixin:
# ── apply ─────────────────────────────────────────────────────────
async def apply_maintenance_plan(self, request):
from .image_delete import IMAGE_DELETE, parse_mode
data, error = await self._read_maintenance_body(request, ("token", "mode"))
data, error = await self._read_maintenance_body(request, ("token",))
if error is not None:
return error
token = data.get("token")
@@ -504,13 +497,6 @@ class MaintenanceRoutesMixin:
plan, refused = self._peek_plan(token)
if refused is not None:
return refused
mode = None
if plan.operation == IMAGE_DELETE:
mode = parse_mode(data.get("mode"))
if mode is None:
return _invalid('mode must be "library" or "trash"')
elif "mode" in data:
return _invalid("Unknown field: mode")
store = self._maintenance_plans
if plan.operation == ROTATED_LOGS:
# Log files only: no database identity, backup or disk check.
@@ -558,8 +544,6 @@ class MaintenanceRoutesMixin:
try:
if plan.operation == DUPLICATE_FILES:
job = self._start_duplicate_cleanup(db, plan, backup)
elif plan.operation == IMAGE_DELETE:
job = self._start_image_delete(db, plan, mode, backup)
elif plan.operation == WORKFLOW_STORAGE:
job = self._start_workflow_storage(db, backup)
else:
@@ -658,7 +642,6 @@ class MaintenanceRoutesMixin:
plan.fingerprint,
payload["targets"],
payload["roots"],
payload.get("marks", ()),
)
except PlanChanged:
return _fail_job(job, "plan-changed", PLAN_CHANGED)
@@ -670,7 +653,7 @@ class MaintenanceRoutesMixin:
)
job.record_counted("changed", result["changed"], rows)
job.record_counted("protected", payload["protected"])
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
line = _changed_line(plan.operation, result["changed"])
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
+1 -60
View File
@@ -8,10 +8,8 @@ import sqlite3
from aiohttp import web
try:
from ...database.library_commands import LibraryCommandError
from ...database.pairs import PairDependencyError
except ImportError:
from database.library_commands import LibraryCommandError
from database.pairs import PairDependencyError
from urllib.parse import quote
@@ -1130,14 +1128,7 @@ class PromptRoutesMixin:
async def bulk_add_tags(self, request):
"""Bulk add tags to prompts."""
try:
try:
data = await request.json()
except ValueError:
return bad_request("Invalid JSON body")
if not isinstance(data, dict):
return bad_request("Expected a JSON object")
if "expected_epoch" in data:
return await self._bulk_library_tags(data)
data = await request.json()
prompt_ids = data.get("prompt_ids", [])
new_tags = data.get("tags", [])
@@ -1189,8 +1180,6 @@ class PromptRoutesMixin:
)
if "images_filter" in data:
return await self._bulk_category_by_filter(data)
if "expected_epoch" in data:
return await self._bulk_library_category(data)
if not data.get("prompt_ids"):
return web.json_response(
{"success": False, "error": "No prompt IDs provided"}, status=400
@@ -1227,54 +1216,6 @@ class PromptRoutesMixin:
status=500,
)
async def _bulk_library_tags(self, data: dict) -> web.Response:
"""The workspace Library's fenced form (#442).
``{prompt_ids, tags, expected_epoch}``: prompt tags only, never image tags.
"""
return await self._fenced_library_write(
data, "tags", self.db.bulk_add_library_tags
)
async def _fenced_library_write(
self, data: dict, field: str, write
) -> web.Response:
"""One fenced Library bulk write: exact keys, checked ids, 409 on a restore."""
fields = {"prompt_ids", field, "expected_epoch"}
if set(data) != fields:
return bad_request(
"Request must contain exactly " + ", ".join(sorted(fields))
)
try:
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
result = await self._run_in_executor(
write, prompt_ids, data[field], expected_epoch=data["expected_epoch"]
)
except LibraryCommandError as exc:
return web.json_response(
{
"success": False,
"code": exc.code,
"library_epoch": exc.epoch,
"error": "Prompt storage was restored; refresh the library",
},
status=409,
)
except ValueError as exc:
return bad_request(str(exc))
return web.json_response({"success": True, **result})
async def _bulk_library_category(self, data: dict) -> web.Response:
"""The workspace Library's fenced form (#437).
``{prompt_ids, category, expected_epoch}``: a non-blank category on
the selected Library parents, refused with 409 ``storage-changed``
when prompt storage was restored since the page read its epoch.
"""
return await self._fenced_library_write(
data, "category", self.db.bulk_set_library_category
)
async def _bulk_category_by_filter(self, data: dict) -> web.Response:
"""Set the category on every linked positive a listing filter admits.
-47
View File
@@ -1,47 +0,0 @@
"""Revalidated responses for the pages and static files PromptManager serves.
``Cache-Control: no-cache`` lets the browser keep a copy but makes it ask the
server before every reuse, so a PromptManager update reaches the browser on a
normal reload (#478). The ETag lets an unchanged file answer 304 without a
body, which keeps that reload cheap.
The tag hashes the bytes rather than the file's mtime and size: an update can
keep both (an archive extract, a same-length edit within one clock tick), and
the handlers read the whole file anyway. It is weak because the gzip
middleware may send the same content in a different encoding.
"""
import hashlib
from aiohttp import web
REVALIDATE = "no-cache"
def entity_tag(body: bytes) -> str:
"""Weak ETag for ``body``."""
return f'W/"{hashlib.sha256(body).hexdigest()[:32]}"'
def etag_matches(if_none_match, etag: str) -> bool:
"""True when an If-None-Match header names ``etag`` (weak comparison)."""
if not if_none_match:
return False
if if_none_match.strip() == "*":
return True
opaque = etag.removeprefix("W/")
return any(
candidate.strip().removeprefix("W/") == opaque
for candidate in if_none_match.split(",")
)
def revalidated_response(request, body: bytes, content_type: str, charset=None):
"""200 with ``body``, or 304 when the request already holds this version."""
etag = entity_tag(body)
headers = {"Cache-Control": REVALIDATE, "ETag": etag}
if etag_matches(request.headers.get("If-None-Match"), etag):
return web.Response(status=304, headers=headers)
return web.Response(
body=body, content_type=content_type, charset=charset, headers=headers
)
+2 -9
View File
@@ -8,8 +8,7 @@ is shared with the LoRA integration's save.
GET also reports the image sources and the monitor (``sources.py``). A save
that changes the sources runs as a ``source_change`` registry hold, so it is
refused while a record-changing job (a scan) runs. ``monitoring_enabled``
takes effect at the next restart; ``save_foreign_runs`` (#247) at once, since
the monitor reads it for every file. ``POST /prompt_manager/settings/validate-source``
takes effect at the next restart. ``POST /prompt_manager/settings/validate-source``
checks one path and writes nothing.
"""
@@ -44,7 +43,6 @@ _SETTINGS_FILE_KEYS = {
"INFINITE_SCROLL": ("web_ui", "infinite_scroll"),
"WORKER_THREADS": ("performance", "worker_threads"),
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
"SAVE_FOREIGN_RUNS": ("gallery", "monitoring", "save_foreign_runs"),
"SAFETY_BACKUP": ("maintenance", "safety_backup"),
"THUMBNAIL_FORMAT": ("thumbnails", "format"),
"THUMBNAIL_QUALITY": ("thumbnails", "quality"),
@@ -60,7 +58,7 @@ _SETTINGS_FILE_KEYS = {
# The thumbnail settings a running Convert outdated job fixes (#317).
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED", "SAVE_FOREIGN_RUNS"})
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
# Attributes that live on AutotagConfig, named without the AUTOTAG_ prefix.
_AUTOTAG_SETTINGS = frozenset(
name for name in _SETTINGS_FILE_KEYS if name.startswith("AUTOTAG_")
@@ -266,7 +264,6 @@ class SettingsRoutesMixin:
"thumbnail_encoders": encoders,
"settings": {
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
@@ -629,10 +626,6 @@ class SettingsRoutesMixin:
if not isinstance(data["monitoring_enabled"], bool):
raise ValueError("monitoring_enabled must be true or false")
updates["MONITORING_ENABLED"] = data["monitoring_enabled"]
if "save_foreign_runs" in data:
if not isinstance(data["save_foreign_runs"], bool):
raise ValueError("save_foreign_runs must be true or false")
updates["SAVE_FOREIGN_RUNS"] = data["save_foreign_runs"]
if "safety_backup" in data:
if not isinstance(data["safety_backup"], bool):
raise ValueError("safety_backup must be true or false")
-100
View File
@@ -1,100 +0,0 @@
"""Where a stale Images target is now (#430): ``POST /prompt_manager/images/rescope``.
A page addresses a listed file as ``{root_index, root_scope, path}``. When the
sources are renumbered its scope no longer names the root at that index, and
every per-file action answers 409 ``source-changed``. Before it gives up, the
page asks here. The target is mapped only when its scope is, at its old
index, the scope of one of the current roots: the server issued that scope
for exactly that folder, so the answer (the file's current listing
descriptor) names nothing the page could not already address. A removed or
replaced root, a scope of another key and a foreign scope stay refused.
Only the Images roots (``_get_all_output_dirs``) are mapped; a LoRA-linked
root's target is never re-scoped here.
"""
import hmac
from pathlib import Path
from aiohttp import web
from .images import (
_forbidden,
_is_thumbnail_cache_path,
_json_body_or_error,
_media_access_error,
)
from .media_target import media_target
from .prompts import bad_request
# Far above any real source count; keeps the index out of huge-int territory.
MAX_ROOT_INDEX = 4095
SOURCE_GONE = (
"The source of this image changed or was removed. Refresh the image "
"collection to find it again."
)
def _coded(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"success": False, "code": code, "error": message}, status=status
)
def _no_store(response: web.Response) -> web.Response:
response.headers["Cache-Control"] = "no-store"
return response
class SourceRescopeMixin:
"""The rescope route; composed into ``PromptManagerAPI``."""
def _register_rescope_routes(self, routes):
@routes.post("/prompt_manager/images/rescope")
async def rescope_route(request):
return _no_store(await self.rescope_image(request))
def _current_root_for(self, scope: str, old_index: int, roots: list) -> int | None:
"""The index of the first current root *scope* was issued for at *old_index*."""
for index, root in enumerate(roots):
if hmac.compare_digest(scope, self._gallery_root_scope(root, old_index)):
return index
return None
def _rescope_blocking(self, scope: str, old_index: int, relative: str):
"""The answer to one rescope (blocking: it resolves paths)."""
roots = list(self._get_all_output_dirs())
index = self._current_root_for(scope, old_index, roots)
if index is None:
return _coded(409, "source-changed", SOURCE_GONE)
root = Path(roots[index])
path = root / relative
denied = _media_access_error(path, [root])
if denied is not None:
return denied
try:
mtime = path.stat().st_mtime if path.is_file() else None
except OSError:
mtime = None
if mtime is None:
return _coded(404, "file-missing", "Image file not found")
[image] = self._format_workspace_page([(path, mtime, root, index)])
return web.json_response({"success": True, "image": image})
async def rescope_image(self, request) -> web.Response:
data, error = await _json_body_or_error(request)
if error is not None:
return error
if set(data) != {"target"}:
return bad_request("Send exactly one target")
target, error = media_target(data["target"])
if error is not None:
return error
old_index, scope, relative = target
if old_index > MAX_ROOT_INDEX:
return bad_request("Invalid gallery root")
if _is_thumbnail_cache_path(relative):
return _forbidden("Previews are not re-scoped; use the original")
return await self._run_in_executor(
self._rescope_blocking, scope, old_index, relative
)
+5 -18
View File
@@ -61,25 +61,18 @@ class VocabularyRoutesMixin:
)
async def _vocabulary_page(
self,
request: web.Request,
kind: str,
reader_name: str,
name: bool,
options: dict | None = None,
self, request: web.Request, kind: str, reader_name: str, name: bool
) -> web.Response:
limit = _vocabulary_limit(request)
cursor = request.query.get("cursor")
options = options or {}
def read() -> dict:
# The snapshot stays open while the cursor is decoded and encoded,
# so its epoch scope is the epoch the page was read from. A tag
# search or sort joins the scope: its cursor fits no other order.
# so its epoch scope is the epoch the page was read from.
with self.db.library_snapshot() as (conn, epoch, _ceiling):
scope = _scope({"kind": kind, "epoch": epoch, **options})
scope = _scope({"kind": kind, "epoch": epoch})
result = getattr(self.db, reader_name)(
conn, epoch, limit=limit, after=_decode(cursor, scope), **options
conn, epoch, limit=limit, after=_decode(cursor, scope)
)
result["next_cursor"] = _encode(
result.pop("next_key"), scope, name=name
@@ -91,14 +84,8 @@ class VocabularyRoutesMixin:
@pair_response
async def list_vocabulary_tags(self, request: web.Request) -> web.Response:
options = {
"search": request.query.get("search", "").strip(),
"sort": request.query.get("sort", "created"),
}
# Name keys are text: kept server side so the cursor stays small.
name = options["sort"].startswith("name")
return await self._vocabulary_page(
request, "vocabulary-tags", "_vocabulary_tags", name, options
request, "vocabulary-tags", "_vocabulary_tags", False
)
@pair_response
-13
View File
@@ -194,8 +194,6 @@ class GalleryConfig:
Attributes:
MONITORING_ENABLED (bool): Enable/disable automatic image monitoring
SAVE_FOREIGN_RUNS (bool): The monitor stores the certain prompt of an
output whose workflow has no PromptManager node (#247)
MONITORING_DIRECTORIES (List[str]): Directories to monitor for new images
SUPPORTED_EXTENSIONS (List[str]): Image file extensions to process
PROCESSING_DELAY (float): Delay in seconds before processing new files
@@ -212,8 +210,6 @@ class GalleryConfig:
# Image monitoring settings
MONITORING_ENABLED = True
# Read by the monitor for every file, so a save applies without a restart
SAVE_FOREIGN_RUNS = True
MONITORING_DIRECTORIES = [] # Auto-detect if empty
SUPPORTED_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
PROCESSING_DELAY = 2.0 # Seconds to wait before processing new files
@@ -255,7 +251,6 @@ class GalleryConfig:
return {
"monitoring": {
"enabled": cls.MONITORING_ENABLED,
"save_foreign_runs": cls.SAVE_FOREIGN_RUNS,
"directories": cls.MONITORING_DIRECTORIES,
"extensions": cls.SUPPORTED_EXTENSIONS,
"processing_delay": cls.PROCESSING_DELAY,
@@ -308,14 +303,6 @@ class GalleryConfig:
config_logger.warning(
"Ignoring gallery monitoring enabled: expected true or false"
)
if "save_foreign_runs" in monitoring:
if isinstance(monitoring["save_foreign_runs"], bool):
cls.SAVE_FOREIGN_RUNS = monitoring["save_foreign_runs"]
else:
config_logger.warning(
"Ignoring gallery monitoring save_foreign_runs: expected true "
"or false"
)
if "directories" in monitoring:
directories = cls._validated_directories(monitoring["directories"])
if directories is not None:
-26
View File
@@ -1,26 +0,0 @@
// #513: a worker records its browser's own log, so a browser that dies between two tests leaves its exit and stderr
// behind instead of only "Target page, context or browser has been closed" on the next test.
import { test, expect } from './fixtures.mjs';
import { isTargetGone } from '../../tools/vendor/browser-logs.mjs';
test('the worker records the launch of the browser its tests use', async ({ browser, browserLogs }) => {
expect(browser.isConnected()).toBe(true);
expect(browserLogs.launches().length).toBeGreaterThan(0);
});
test('a killed browser leaves its signal and stderr in the worker log', async ({ playwright, browserName, browserLogs }) => {
const before = browserLogs.launches();
const victim = await playwright[browserName].launch();
const pid = browserLogs.launches().find(item => !before.includes(item));
expect(pid, 'the new browser\'s launch was recorded').toBeGreaterThan(0);
const disconnected = new Promise(resolve => victim.once('disconnected', resolve));
// Playwright starts a browser in a process group of its own; WebKit's launched pid is a wrapper script.
process.kill(-pid, 'SIGKILL');
await disconnected;
const error = await victim.newContext().then(() => null, item => item);
expect(error, 'a dead browser refuses a new context').not.toBeNull();
expect(isTargetGone(error.message)).toBe(true);
// The pipe can close before the launcher has logged the exit.
await expect.poll(() => browserLogs.exits()).toContainEqual({ pid, exitCode: null, signal: 'SIGKILL' });
expect(browserLogs.tail()).toContain(`[pid=${pid}] <process did exit: exitCode=null, signal=SIGKILL>`);
});
-108
View File
@@ -1,108 +0,0 @@
import {test,expect} from './fixtures.mjs';
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
// #331 D138: the Images selection bar's "Add to collection" dialog against the concept's own (`bulk-collection` →
// simpleInput, app.js a1392–1398 and a1138), on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
// Paired screenshots are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
/**
* Runs in the page: the open "Add selected images to collection" dialog: its title, the body's tree, the field's
* label, input and suggestions, and the actions, with the concept's geometry and tokens.
*/
function measureAddDialog() {
const dialog=document.querySelector('dialog#modal[open]');
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
const name=node=>[node.localName,...[...node.classList].sort()].join('.');
const text=node=>node?.textContent.trim()??null;
const body=dialog.querySelector('#modal-body');
const field=body.querySelector(':scope > .field');
const label=field?.querySelector(':scope > label');
const input=field?.querySelector(':scope > input');
const list=input?.list??null;
const actions=body.querySelector(':scope > .dialog-actions');
const rect=dialog.getBoundingClientRect();
return {
title:text(dialog.querySelector('#modal-title')),
tree:[...body.children].map(name),
field:field&&{tree:[...field.children].map(name),height:Math.round(field.getBoundingClientRect().height),
...css(field,'display','flexDirection','rowGap','marginBottom')},
label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')},
input:input&&{id:input.id,type:input.type,value:input.value,maxLength:input.maxLength,list:input.getAttribute('list'),
...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')},
suggestions:list&&{id:list.id,display:getComputedStyle(list).display,options:[...list.options].map(option=>option.value)},
actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','marginTop'),
buttons:[...actions.children].map(button=>({text:text(button),className:button.className,
...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor')}))},
width:Math.round(rect.width),height:Math.round(rect.height),
};
}
/**
* D138: the action is "Add" (the concept's "Apply in prototype"), the field starts empty (no sample "Experiments") and
* holds at most 100 characters, and a hidden `datalist` of the existing names (the fixture's two) follows the field:
* the shared input dialog's suggestions list, as Set category's (#332 D140).
*/
function mapAddDialogConcept(reference) {
const [cancel,apply]=reference.actions.buttons;
const [field,...rest]=reference.tree;
return {...reference,tree:[field,'datalist',...rest],
input:{...reference.input,value:'',maxLength:100,list:'workspace-input-suggestions'},
suggestions:{id:'workspace-input-suggestions',display:'none',options:['Experiments','Landscapes']},
actions:{...reference.actions,buttons:[cancel,{...apply,text:'Add'}]}};
}
async function openAddDialog(page,main) {
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.locator(`${main} #bulkbar`).getByRole('button',{name:'Add to collection',exact:true}).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add selected images to collection');
await page.mouse.move(0,0);
return page.evaluate(measureAddDialog);
}
async function conceptAddDialog(page,theme) {
await openConcept(page,'images',theme);
return openAddDialog(page,'#main');
}
async function productionAddDialog(page,theme) {
await openProduction(page,'images',theme);
await expect(page.locator(CARDS).first()).toBeVisible();
return openAddDialog(page,'#workspace-main');
}
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
for(const theme of THEMES)for(const [width,height] of SIZES) {
test(`add to collection dialog matches the concept ${theme} ${width}x${height}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const concept=await conceptAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`concept-add-to-collection-${theme}-${width}.png`)});
const production=await productionAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`production-add-to-collection-${theme}-${width}.png`)});
expect(concept.input.value,'the concept pre-fills its sample').toBe('Experiments');
expect(diffInventory(mapAddDialogConcept(concept),production)).toEqual([]);
});
}
test('add to collection comparison detects mutations',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
const concept=await conceptAddDialog(page,'dark');
const expected=mapAddDialogConcept(concept);
const production=await productionAddDialog(page,'dark');
expect(diffInventory(expected,production)).toEqual([]);
// Unmapped, the concept differs only where D138 is recorded.
expect(diffInventory(concept,production).map(item=>item.path).sort()).toEqual(expect.arrayContaining(
['actions.buttons.1.text','tree.1','input.list','input.maxLength','input.value']));
const paths=async()=>diffInventory(expected,await page.evaluate(measureAddDialog)).map(item=>item.path);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Name';});
expect(await paths()).toEqual(['label.text']);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Collection name';});
const undoStyle=await mutate(page,{css:'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }'});
expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor','actions.columnGap']));
await undoStyle();
await page.evaluate(()=>document.querySelector('#workspace-input-suggestions').remove());
expect(await paths()).toEqual(expect.arrayContaining(['suggestions']));
});
-62
View File
@@ -118,27 +118,9 @@ export async function openConcept(page,route,theme,motion='no-preference') {
await expect(page.locator('#main #page-title')).toBeVisible();
}
/**
* D157 (#451): production opens Images in Masonry when the browser has no Default layout; the concept starts at Grid.
* A comparison (and a test of the Grid's own geometry) starts production from the concept's state, a stored Default
* layout of Grid, once per tab and only while this browser stored nothing (a test's own settings win).
*/
export async function startInGrid(page) {
if (page.conceptLayoutSeeded) return;
page.conceptLayoutSeeded = true;
await page.addInitScript(() => {
try {
if (!location.pathname.startsWith('/prompt_manager/') || sessionStorage.getItem('pm-d157-seeded')) return;
sessionStorage.setItem('pm-d157-seeded', '1');
if (localStorage.getItem('gallerySettings') === null) localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' }));
} catch { /* A page without storage keeps the production default. */ }
});
}
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
export async function openProduction(page,route,theme,motion='no-preference') {
await page.emulateMedia({reducedMotion:motion});
await startInGrid(page);
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
@@ -232,41 +214,6 @@ export async function withChipSource(page,root,origin,category=null) {
};
}
/**
* #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
* has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
* runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
* holding the concept's row and each further one a copy of it. Returns the undo.
*/
export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.tag-section .role-label { color: var(--ca-muted); }
.tag-section > .tags { margin-top: 0; }`;
export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
export async function withTagSections(page,root,sections=PROMPT_TAGS) {
const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
const token=await page.evaluate(({root,sections})=>{
const container=document.querySelector(root);
const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
const anchor=row.nextSibling,parent=row.parentNode,added=[];
sections.forEach(([className,label,name],index)=>{
const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
const heading=document.createElement('div');heading.className='prompt-heading';
const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
heading.append(text);section.append(heading,index?row.cloneNode(true):row);
parent.insertBefore(section,anchor);added.push(section);
});
const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
(window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
return token;
},{root,sections});
return async()=>{
await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
await restoreRule();
};
}
/**
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
@@ -715,13 +662,6 @@ export function measureInspector(selector) {
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function tagSectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
row:when(row,rowOf)};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
@@ -755,8 +695,6 @@ export function measureInspector(selector) {
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
// #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
+1 -17
View File
@@ -4,29 +4,13 @@
// points at that worker's server. A server that dies fails the running test
// with its stderr tail, and the next test in the worker gets a fresh server.
// A test that fails gets the stderr its server wrote during it attached as
// `server-stderr`. The worker also records its browser's own log (#513): a
// browser that exits abnormally prints one `pm-browser-exit|` line, and a test
// that fails on a closed or crashed target gets that log attached as
// `browser-stderr`.
// `server-stderr`.
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
export const test = base.extend({
browserLogs: [async ({}, use) => {
const recorder = createBrowserLogRecorder();
try {
await use(recorder);
} finally {
recorder.uninstall();
}
}, { scope: 'worker', auto: true }],
browserLogsOnFailure: [async ({ browserLogs }, use, testInfo) => {
await use();
await attachBrowserLogs(testInfo, browserLogs);
}, { auto: true }],
testServer: [async ({}, use) => {
const supervisor = createServerSupervisor({ start: () => startBrowserServer() });
try {
+24 -45
View File
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
@@ -139,21 +139,16 @@ test('panel matches the concept order and copy',async({page,request})=>{
// #332 MC8: the chip names where its value came from, in visible text after the category.
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
// (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
await expect(recipeTags).toHaveClass('tag-section linked-tags');
await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.tag-section button')).toHaveCount(0);
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
// #331 D139: every listed file's footer has Collections… (folder icon); #480 D159: Delete… ends it.
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…','Delete…']);
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
@@ -162,30 +157,27 @@ test('panel matches the concept order and copy',async({page,request})=>{
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…','Delete…']);
// Unlinked: no badge, category, tags, pair or history; the footer holds Collections… (D139) and Delete… (D159).
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
// Unlinked: no badge, category, tags, pair or history.
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…','Delete…']);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0);
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.tag-section.linked-tags > .tags');
const tagRow=panel.locator('.detail-inner > .tags').last();
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
@@ -249,7 +241,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click();
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
@@ -266,7 +258,7 @@ test('pair send and history appear only with real roles and link',async({page,re
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…','Delete…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
});
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
@@ -294,19 +286,19 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']});
// then the pair send and history buttons (D20).
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
const provenance=details.locator('details').first();await provenance.locator('summary').click();
@@ -317,9 +309,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
// D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
@@ -333,10 +323,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
// No badge, no line; no resolved pair or link: after the details only D139's Collections….
// No badge, no line; no resolved pair or link, no buttons after the details.
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
expect((await viewerStructure(details)).outer).toEqual(['div','button']);
await expect(details.locator(':scope > button')).toHaveText(['Collections…']);
expect((await viewerStructure(details)).outer).toEqual(['div']);
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
// The history button leaves the viewer for the prompt's history.
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
@@ -475,7 +464,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
@@ -558,8 +547,7 @@ test.describe('full inspector coarse pointer',()=>{
}
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
// #450 D156: the view switch is hidden at 700px and below; a shown one is measured like ×.
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button:visible').all()) {
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) {
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
}
@@ -622,15 +610,6 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
const production=await page.evaluate(VIEWER_SHELL);
// C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
// #450 D156: production's view switch sits right before × (hidden at 700px and below); the concept has none.
const modePaths=production.paths.filter(path=>path.includes('button#viewer-mode'));
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
production.paths=production.paths.filter(path=>!modePaths.includes(path));
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
expect(production.toolbar[8].text).toBe('Delete…');
production.toolbar.splice(8,1);
production.paths=production.paths.filter(path=>!path.includes('button.danger'));
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
const appended=production.toolbar.slice(9);
+8 -58
View File
@@ -30,16 +30,11 @@ function captionDay(seconds,now=new Date()) {
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
}
/**
* D8 (amended by #249 PR D and #331): the selection bar's real actions only. Add to collection compares in place
* (D138). #480 D159: Remove links… returns as Delete…; OMITTED_BULK names the concept button it replaces.
*/
const OMITTED_BULK=Object.freeze(['Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */
const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
/** #480 D159: Delete… takes the place of Remove links… (the concept's `danger` button, under another text). */
const DELETE_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-remove"]',text:'Delete…'}]);
/**
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
@@ -94,8 +89,7 @@ export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...reference.toolbar};
// D153 (#447): production offers no "Highest rated".
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Name Z–A']}:item);
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
@@ -454,12 +448,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
const removed=(await inventoryOf(page,'#main')).bulkbar;
await restore();
// The oracle for production's bar: the concept page itself with D85's button and D159's Delete… in place of
// Remove links….
const restoreClones=await mutate(page,{clone:[...AUTO_TAG_CLONE,...DELETE_CLONE]});
const restoreRemoved=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
// The oracle for production's bar: the concept page itself without the omitted actions and with D85's button.
const restoreProduction=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS],clone:AUTO_TAG_CLONE});
const oracle=await inventoryOf(page,'#main');
await restoreRemoved();await restoreClones();
await restoreProduction();
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
await openProductionLoaded(page,theme,'no-preference');
const emptyActual=await inventoryOf(page,'#workspace-main');
@@ -472,7 +464,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
'Add to collection','Export metadata','Remove links…','Clear']);
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
'Set category','Add to collection','Export metadata','Delete…','Clear']);
'Set category','Export metadata','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
@@ -480,48 +472,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
});
/**
* #331 D137: the collection page's Edit leads the heading actions, drawn in the concept as a plain copy of its Import
* button reading "Edit" (no icon, no action), so the browser lays out what follows it; returns an undo.
*/
async function drawCollectionEdit(page) {
await page.evaluate(()=>{
const actions=document.querySelector('#main .heading-actions');
const edit=actions.querySelector('button').cloneNode(false);
for(const key of Object.keys(edit.dataset))delete edit.dataset[key];
edit.textContent='Edit';actions.prepend(edit);window.__collectionEdit=edit;
});
await settle(page);
return ()=>page.evaluate(()=>{window.__collectionEdit.remove();delete window.__collectionEdit;});
}
// #331 D137: `#/collection/<id>` against the concept's `#collection-landscapes` (app.js a1152–1157, a1186–1189): the
// collection's name as the h1, "Collection" as the breadcrumb, the Images description, toolbar and filter row. The grid
// is not compared: the concept's members come from its categories, production's from the stored collection.
for(const [width,height] of SIZES)for(const theme of THEMES)test(`collection page heading, toolbar and filter row match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
await openConcept(page,'collection-landscapes',theme);await page.mouse.move(0,0);await settle(page);
await expect(page.locator('#main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#breadcrumb')).toHaveText('Collection');
const restore=await drawCollectionEdit(page);
const reference=await inventoryOf(page,'#main');
await restore();
await openProduction(page,`collection/${manifest.collections.Landscapes}`,theme);await page.mouse.move(0,0);
await expect(page.locator(CARDS)).toHaveCount(12);
await expect(page.locator('#workspace-main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Collection');
await expect(page).toHaveTitle('Collection · Prompt Manager');
await settle(page);
const actual=await inventoryOf(page,'#workspace-main');
expect.soft(actual.heading.actions.buttons.map(item=>item.text),'production heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(reference.heading.actions.buttons.map(item=>item.text),'mapped concept heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(compare(reference,actual,manifest,['heading','toolbar','filterRow']),'collection page').toEqual([]);
// The comparison notices a lost Edit.
await mutate(page,{remove:['#workspace-main .heading-actions > button:first-child']});
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['heading']).length).toBeGreaterThan(0);
});
/** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */
const IMAGE_CATEGORY='Portraits';
async function setImageCategory(request,path,category) {
@@ -1,106 +0,0 @@
import {test,expect} from './fixtures.mjs';
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
// #480 (D159): the Images selection bar's Delete… against the concept's "Remove links…" (app.js a432, `danger`) and
// its review against the concept's `maintenancePlan` (a953–957): the bar button's place and style, the review's
// `dl.meta-grid` rows and its actions, on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
// Paired screenshots are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
/** Runs in the page: the selection bar's danger button and where it sits among the bar's buttons. */
function measureBar() {
const bar=document.querySelector('#bulkbar > .bulkbar');
const buttons=[...bar.querySelectorAll(':scope > button')];
const danger=bar.querySelector(':scope > button.danger');
const css=(node,...keys)=>Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]]));
return {
// The danger action sits right before Clear, the last button.
dangerBeforeClear:buttons.indexOf(danger)===buttons.length-2,
lastText:buttons.at(-1).textContent.trim(),
danger:css(danger,'color','backgroundColor','borderTopColor','fontSize','fontWeight','paddingTop','paddingLeft',
'borderTopLeftRadius','height'),
};
}
/** Runs in the page: the open review's grid and actions (the parts production shares with the concept). */
function measureReview() {
const dialog=document.querySelector('dialog#modal[open]');
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
const grid=dialog.querySelector('#modal-body dl.meta-grid');
const entry=grid.querySelector(':scope > div');
const actions=dialog.querySelector('#modal-body > .dialog-actions');
const [cancel]=actions.children;
return {
grid:css(grid,'display','gridTemplateColumns','columnGap','rowGap'),
dt:css(entry.querySelector('dt'),'fontSize','color','textTransform','letterSpacing'),
dd:css(entry.querySelector('dd'),'fontSize','color','marginLeft'),
terms:[...grid.querySelectorAll('dt')].map(dt=>dt.textContent.trim()),
actions:css(actions,'display','justifyContent','columnGap','marginTop'),
cancel:{text:cancel.textContent.trim(),...css(cancel,'backgroundColor','color','fontSize','paddingTop','paddingLeft')},
};
}
async function checkFirst(page) {
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
}
async function concept(page,theme) {
await openConcept(page,'images',theme);
await checkFirst(page);
const bar=await page.evaluate(measureBar);
await page.locator('#main #bulkbar').getByRole('button',{name:'Remove links…',exact:true}).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
await page.mouse.move(0,0);
return {bar,review:await page.evaluate(measureReview)};
}
async function production(page,theme) {
await openProduction(page,'images',theme);
await expect(page.locator(CARDS).first()).toBeVisible();
await checkFirst(page);
const bar=await page.evaluate(measureBar);
await page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Delete…',exact:true}).click();
const dialog=page.locator('dialog#modal[open]');
await expect(dialog.locator('#modal-title')).toHaveText('Delete 1 image');
await dialog.getByRole('radio',{name:/^Remove from the library only/}).check();
await page.mouse.move(0,0);
return {bar,review:await page.evaluate(measureReview)};
}
/**
* D159: production's rows follow the chosen mode (Scope, then what the files lose, Backup and Media on disk; the
* concept's sample has Scope, Protected, Backup and Media on disk). The geometry, tokens and the Cancel button match.
*/
function mapConcept(reference,actual) {
return {...reference,review:{...reference.review,terms:actual.review.terms}};
}
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
for(const theme of THEMES)for(const [width,height] of SIZES) {
test(`delete matches the concept's remove links ${theme} ${width}x${height}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const reference=await concept(page,theme);
await page.screenshot({path:testInfo.outputPath(`concept-remove-links-${theme}-${width}.png`)});
const actual=await production(page,theme);
await page.screenshot({path:testInfo.outputPath(`production-delete-${theme}-${width}.png`)});
expect(reference.review.terms).toEqual(['Scope','Protected','Backup','Media on disk']);
expect(actual.review.terms[0]).toBe('Scope');
expect(actual.review.terms.slice(-2)).toEqual(['Backup','Media on disk']);
expect(diffInventory(mapConcept(reference,actual),actual)).toEqual([]);
});
}
test('delete comparison detects mutations',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
const reference=await concept(page,'dark');
const actual=await production(page,'dark');
const expected=mapConcept(reference,actual);
expect(diffInventory(expected,actual)).toEqual([]);
const undo=await mutate(page,{css:'dialog#modal dl.meta-grid { column-gap: 31px; } dialog#modal .dialog-actions { gap: 29px; }'});
const paths=diffInventory(expected,{...actual,review:await page.evaluate(measureReview)}).map(item=>item.path);
expect(paths).toEqual(expect.arrayContaining(['review.grid.columnGap','review.actions.columnGap']));
await undo();
});
+10 -17
View File
@@ -9,7 +9,7 @@ import { parityPng } from './local-inspect-files.mjs';
// `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722,
// 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the
// same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded
// differences D60–D66 and D155 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
// is measured, so the browser lays the concept out with production's words. After that the diff must be empty.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
@@ -22,8 +22,7 @@ const COPY = {
'Read embedded metadata in this browser. The file is not uploaded or added to your library.'],
chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'],
note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.',
// D155 (#448): choosing the file opens its metadata.
'Your file stays in this browser. Choosing it opens its metadata.'],
'Your file stays in this browser. Its metadata opens with “Open file metadata”.'],
toast: ['Local preview only. No file uploaded; sample metadata is available above.',
'Local preview only. No file uploaded; metadata is available above.'],
};
@@ -213,29 +212,23 @@ async function productionStates(page, theme, testInfo, tag) {
await settle(page);
const initial = await page.evaluate(measureInspect, '#workspace-main');
await captureCanonicalState(page, testInfo, `production-initial-${tag}`);
// D155 (#448): choosing the file opens the viewer with its details at once (the concept waits for its heading
// action), so the viewer state is measured first; the selected page is measured after Escape, with the focus the
// close returned to the chooser removed (the concept's page has no focused control there).
await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE));
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
await page.mouse.move(0, 0);
await settle(page);
const viewer = await page.evaluate(measureViewer);
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
await page.keyboard.press('Escape');
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
await page.evaluate(() => document.activeElement?.blur());
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
// D155: no toast; the opened viewer was the confirmation.
await expect(page.locator('#workspace-toast')).toHaveText('');
await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]);
await expect(action).toHaveAttribute('aria-disabled', 'false');
// D63: a readable PNG shows no status line.
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
await settle(page);
const selected = await page.evaluate(measureInspect, '#workspace-main');
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
await action.click();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
await page.mouse.move(0, 0);
await settle(page);
const viewer = await page.evaluate(measureViewer);
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
return { initial, selected, viewer };
}
+29 -89
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -51,13 +51,10 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
* tag sections the concept page gets at runtime from `withTagSections`); everything else must
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details; #480's
* D159 "Delete…" follows it in the panel footer.
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
@@ -110,43 +107,18 @@ async function openProductionPanel(page,theme) {
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await settle(page);
}
/**
* #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own
* Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo.
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
* without its action); returns an undo.
*/
async function withCollectionsButton(page,selector) {
await page.evaluate(selector=>{
const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history'));
const button=history.cloneNode(false);
for(const key of Object.keys(button.dataset))delete button.dataset[key];
button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…');
history.after(button);(window.__collectionsButtons??=[]).push(button);
// #480 D159: Delete… ends the panel's actions, drawn as the concept's own `danger` button (its Remove links…).
if(selector.startsWith('#image-inspector')) {
const remove=history.cloneNode(false);
for(const key of Object.keys(remove.dataset))delete remove.dataset[key];
remove.className='danger';remove.textContent='Delete…';
button.after(remove);window.__collectionsButtons.push(remove);
}
},selector);
await settle(page);
return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});};
}
/**
* The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's
* coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button
* (its "Download sample JSON", without its action); returns an undo.
*/
async function plainViewerChips(page,sections) {
async function plainViewerChips(page) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
const restoreSections=await withTagSections(page,'#viewer-details',sections);
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
@@ -157,12 +129,11 @@ async function plainViewerChips(page,sections) {
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
});
const restoreCollections=await withCollectionsButton(page,'#viewer-details');
await settle(page);
return async()=>{
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await restoreSections();await restoreSource();await restoreTargets();
await restoreSource();await restoreTargets();
};
}
@@ -180,7 +151,7 @@ async function fileControlsOf(page) {
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
async function viewerOf(page,concept,mutation=null) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
@@ -189,7 +160,7 @@ async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
@@ -197,18 +168,13 @@ async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false,sections=PROMPT_TAGS) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
// labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
async function inventory(page,concept=false) {
// D146: the concept panel's category chip with its visible origin, laid out by the concept page itself.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
await restoreSections?.();
await restoreCollections?.();
await restoreSource?.();
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
@@ -272,8 +238,7 @@ async function productionSendOf(page,context) {
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
// The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
await settle(page);
@@ -338,9 +303,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
// D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
@@ -370,18 +332,15 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const badges=panelOf(page).locator('.detail-inner > .tags');
await expect(badges).toHaveCount(1);
await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
// D146 (#332): the visible origin names where the value came from.
await expect(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
// D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(tags.locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…','Delete…']);
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
});
// #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the
@@ -394,7 +353,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
async route=>{arrived();await held;await route.continue();},{times:1});
const count=page.locator('#result-count');
await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await reached;
await expect(count).toHaveText('Loading images…');
await expect(page.locator(CARDS)).toHaveCount(0);
@@ -404,9 +363,8 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
// and equals the concept with its row in those two sections, in both sizes.
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
@@ -414,14 +372,13 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
const reference=await inventory(page,true);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
@@ -446,17 +403,6 @@ test('inspector comparison detects mutations',async({page})=>{
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
// D154: the tag section's muted label and its row's spacing are part of the mapped concept.
for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
const restore=await mutate(page,{css});
const mutated=await measure();
expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
await restore();await settle(page);
}
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
@@ -468,12 +414,6 @@ test('inspector comparison detects mutations',async({page})=>{
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
// D139: Collections… is part of the mapped concept, so losing it is a difference.
const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']});
const withoutCollections=await measure();
expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/);
await restoreButton();await settle(page);
// D146: the visible origin is part of the mapped concept, so losing it is a difference.
const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']});
const sourceMutated=await measure();
+9 -78
View File
@@ -21,8 +21,8 @@ export function mapLibraryConcept(reference) {
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
// D14: the Library appends its Oldest first after the concept's three sorts.
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
return item;
@@ -30,26 +30,18 @@ export function mapLibraryConcept(reference) {
// A shorter toolbar moves what follows it up by as much.
const rise=library.toolbar.height-toolbar.height;
const cards=library.cards.map((card,index)=>({...card,
// D148 (#437): each card leads with the concept's Images `.image-overlays` selection checkbox, placed absolutely
// over the art, so nothing else moves; it is compared with the concept's own overlay below.
children:['image-overlays',...card.children],
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
tags:sortedTags(card.tags),
// D153 (#447): no rating stars; the concept page itself is measured without them (`dropConceptRatings`). The
// fixture's run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own
// "4 runs" and "Used today". The two-line clamp is
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
// run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs"
// and "Used today". The two-line clamp is
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
footer:card.footer}));
// D152 (#443): Show archived prompts (N) comes first in the heading actions, a plain button like Export (the concept
// fixture has nothing archived, so N is 0).
const actions=reference.heading.actions;
const plain=actions.buttons.find(button=>!button.primary);
const heading={...reference.heading,actions:{...actions,buttons:[{...plain,text:'Show archived prompts (0)'},...actions.buttons]}};
return {...reference,heading,library:{...library,toolbar,
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
return {...reference,library:{...library,toolbar,
// D35: the caption uses the singular for one.
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
@@ -144,20 +136,9 @@ async function sortConceptTags(page) {
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
}
/**
* D153 (#447): production draws no 1–5 rating. The concept's card footers read "★★★★★ · Used today"; the stars and
* their separator are taken out of the concept page itself, so the footer facts are measured as production draws them.
* Returns the concept's original footer texts.
*/
async function dropConceptRatings(page) {
return page.evaluate(()=>[...document.querySelectorAll('.prompt-card .card-footer > span')].map(span=>{
const original=span.textContent;span.textContent=original.replace(/^★+ · /,'');return original;}));
}
async function openConceptLibrary(page,theme,motion) {
await openConcept(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
const footers=await dropConceptRatings(page);
expect(footers,'the concept rates every card (D153 removes it)').toEqual(Array(CONCEPT_PROMPTS).fill().map((_,index)=>expect.stringMatching(/^★+ · \S/)));
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function openProductionLibrary(page,theme,motion) {
@@ -311,7 +292,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
expect.soft(card.footer.facts.text,`${motion} concept footer facts (D153: without the stars)`).toBe('Used today');
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
@@ -330,56 +311,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
/**
* D148 (#437): the selection checkbox overlay of `card` and the `.bulkbar` row inside `main`, with one item checked,
* measured alike in the concept's Images page and production's Library: the overlay's inset in its card, the checkbox,
* and the bar's box, tokens and the actions both bars have (Add tags, Set category, Clear; #442).
*/
const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const rect=node=>node.getBoundingClientRect(),px=value=>Math.round(value*100)/100;
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','color');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginBottom','borderTopWidth','borderTopStyle',
'borderTopColor','borderTopLeftRadius','backgroundColor');
const host=document.querySelector(card),overlay=host.querySelector('.image-overlays'),label=overlay.querySelector(':scope > label');
const input=label.querySelector('input'),root=document.querySelector(main),bar=root.querySelector('.bulkbar');
const shared=['Add tags','Set category','Clear'];
return {
overlay:{left:px(rect(overlay).left-rect(host).left),top:px(rect(overlay).top-rect(host).top),right:px(rect(host).right-rect(overlay).right),
...css(overlay,'position','display','justifyContent','pointerEvents')},
label:css(label,'pointerEvents'),checkbox:{width:px(rect(input).width),height:px(rect(input).height),checked:input.checked,...css(input,'accentColor')},
bar:{left:px(rect(bar).left-rect(root).left),...css(bar,'display','alignItems','columnGap','rowGap','flexWrap'),...edges(bar),...font(bar),
count:{text:bar.querySelector(':scope > strong').textContent,...font(bar.querySelector(':scope > strong'))},
buttons:[...bar.querySelectorAll(':scope > button')].filter(button=>shared.includes(button.textContent.trim()))
.map(button=>({text:button.textContent.trim(),height:px(rect(button).height),...font(button),...edges(button)}))},
};
},{card,main});
async function conceptSelection(page,theme) {
await openConcept(page,'images',theme);
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:'#image-grid .media-card',main:'#main'});
}
async function productionSelection(page,theme) {
await openProductionLibrary(page,theme,'no-preference');
await page.locator(CARDS).first().locator('.image-overlays input').check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:CARDS,main:'#workspace-main'});
}
for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library selection checkbox and bar match the concept's Images ones ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const reference=await conceptSelection(page,theme);
await captureCanonicalState(page,testInfo,`concept-images-selection-${width}-${theme}`);
const actual=await productionSelection(page,theme);
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
expect(actual).toEqual(reference);
// The comparison notices a moved checkbox (spec §7 mutation check).
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
});
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width:1800,height:1100});
@@ -410,7 +341,7 @@ test('a run that crosses midnight still reads the concept fixture\'s day',async(
const reference=await conceptInventory(page);
await openProductionLibrary(page,'dark','no-preference');
const actual=await inventoryOf(page,'#workspace-main');
expect(actual.library.cards.map(card=>card.footer.facts.text)).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
@@ -1,148 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
// #443 D152: the Library's Delete… review against the concept's own reviewed archive: the recipe editor's "Archive…"
// (app.js 976), which opens `maintenancePlan("archive <title>")` (953–958, 1309–1310). The concept's shell, grid tokens,
// `p.small` and actions are kept; its title, demo note, rows, note text and `ca-primary` start are replaced by D152's,
// and the start takes the concept's `danger` class, measured on the editor's own "Archive…". The bar's Delete… is
// compared with the concept Images bar's danger action, "Remove links…" (app.js 432).
test.use({ locale: 'en-US', timezoneId: 'UTC' });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
page.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.conceptErrors).toEqual([]);
});
/** Runs in the page: the open review dialog's title, body tree, grid, notes and actions with the concept's tokens. */
function measureReview() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const grid = body.querySelector(':scope > dl.meta-grid');
const notes = [...body.querySelectorAll(':scope > p.small')];
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name).filter(item => item !== 'p.demo-note' && item !== 'h3'),
grid: { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
notes: notes.map(note => ({ text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') })),
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
/** Runs in the page: a button's class and tokens (the concept's danger button, or production's). */
function measureButton(selector) {
const node = document.querySelector(selector);
const style = getComputedStyle(node);
return { className: node.className, ...Object.fromEntries(['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop',
'paddingLeft', 'borderTopColor', 'borderTopLeftRadius', 'height'].map(key => [key, key === 'height'
? `${Math.round(node.getBoundingClientRect().height * 100) / 100}px` : style[key]])) };
}
async function conceptReview(page, theme) {
await openConcept(page, 'prompt/alpine', theme);
await page.locator('#main .heading-actions').getByRole('button', { name: 'Edit metadata', exact: true }).click();
const archive = page.locator('dialog#modal[open] .dialog-actions button', { hasText: 'Archive…' });
await expect(archive).toBeVisible();await page.mouse.move(0, 0);
const danger = await page.evaluate(measureButton, 'dialog#modal[open] .dialog-actions button.danger');
await archive.click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
await page.mouse.move(0, 0);
return { danger, review: await page.evaluate(measureReview) };
}
async function seedMixed(request) {
// One unpaired prompt beside the concept's paired ones, so the review deletes one and archives one.
await expect(await request.post('/prompt_manager/save', { data: { text: 'Unpaired study', category: 'Studies' } })).toBeOK();
const items = (await (await request.get('/prompt_manager/library?limit=100')).json()).items;
return { unpaired: items.find(item => item.text === 'Unpaired study'), paired: items.find(item => item.variant_count > 0) };
}
async function productionReview(page, request, theme) {
const { unpaired, paired } = await seedMixed(request);
await openProduction(page, 'library', theme);
for (const item of [unpaired, paired]) await page.locator(`article[data-prompt-id="${item.id}"] .image-overlays input`).check();
await page.locator('#library-bulkbar .bulkbar').getByRole('button', { name: 'Delete…', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Delete or archive selected prompts');
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
/** D152's changes to the concept's review; everything else must equal the concept. */
function mapDeleteConcept({ danger, review }) {
const [cancel] = review.actions.buttons;
const note = review.notes.at(-1);
return {
...review,
title: 'Delete or archive selected prompts',
// #482: the paired prompt is deletable, so the review asks Archive or Delete permanently (a D90 radio row).
tree: ['dl.meta-grid', 'div.field', 'p.small', 'p.small', 'div.dialog-actions'],
grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'],
['Archive', '1 prompt in a saved pair: hidden, restorable'],
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting']] },
notes: [
{ ...note, text: 'Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the '
+ 'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, '
+ 'select them and press Restore.' },
// A second paragraph takes the concept's own `p + p` margin (style.css 93-95).
{ ...note, marginTop: '10px', text: "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and "
+ 'image links. Deleted prompts cannot be brought back one by one: the only way back is to restore the safety '
+ 'backup taken first (System › Backup & export), which returns the whole library to that moment. It is kept '
+ 'among the last 10 safety backups. Image files, favorites, collections and recorded runs stay. A later scan, a '
+ 'new image or a node run with the same text adds it again as a new prompt.' },
],
actions: { ...review.actions, tree: [review.actions.tree[0], 'button.danger'], buttons: [cancel, { text: 'Delete 1 and archive 1 prompt', className: danger.className,
backgroundColor: danger.backgroundColor, color: danger.color, fontSize: danger.fontSize, paddingTop: danger.paddingTop,
paddingLeft: danger.paddingLeft, borderTopColor: danger.borderTopColor }] },
};
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`the Delete… review matches the concept's reviewed archive ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptReview(page, theme);
expect(concept.review.tree).toEqual(['dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-archive-review-${theme}-${width}.png`) });
const production = await productionReview(page, request, theme);
await page.screenshot({ path: testInfo.outputPath(`production-delete-review-${theme}-${width}.png`) });
expect(diffInventory(mapDeleteConcept(concept), production)).toEqual([]);
});
}
test('the Delete… review comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptReview(page, 'dark');
await productionReview(page, request, 'dark');
const expected = mapDeleteConcept(concept);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await mutate(page, { css: '#modal-body .dialog-actions .danger { padding-left: 2px }' });
expect(await paths()).toContain('actions.buttons.1.paddingLeft');
});
test('the bar\'s Delete… matches the concept Images bar\'s danger action', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openConcept(page, 'images', 'dark');
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
const reference = await page.evaluate(measureButton, '#bulkbar .bulkbar button.danger');
const { paired } = await seedMixed(request);
await openProduction(page, 'library', 'dark');
await page.locator(`article[data-prompt-id="${paired.id}"] .image-overlays input`).check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
expect(await page.evaluate(measureButton, '#library-bulkbar .bulkbar button.danger')).toEqual(reference);
});
@@ -25,16 +25,9 @@ export function inventoryOf(dialog) {
demo:Boolean(dialog.querySelector('.demo-note')),
};
}
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
// concept's "Rating" select is not drawn. D161 (#481): the concept's one danger "Archive…" becomes "Archive variant…" and
// is followed by the prompt's own "Delete prompt…", drawn as the same danger button.
function mapButton(item) {
if(item.text==='Save in prototype') return [{...item,text:'Save'}];
if(item.text==='Archive…') return [{...item,text:'Archive variant…'},{...item,text:'Delete prompt…'}];
return [item];
}
// Documented differences only: prototype Save label, prototype demo note, and data-dependent values.
export function mapConcept(reference) {
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.flatMap(mapButton)}};
return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
}
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
+1 -1
View File
@@ -24,7 +24,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled();
await editor(page).getByRole('button',{name:'Archive variant…',exact:true}).click();
await editor(page).getByRole('button',{name:'Archive…',exact:true}).click();
await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive');
let release;const held=new Promise(resolve=>{release=resolve;});
-31
View File
@@ -1,31 +0,0 @@
"""Keep the browser test server's large frees going back to the OS (#516).
glibc serves an allocation of at least the mmap threshold (128 KiB at start) with its
own mmap and unmaps it on free. Freeing such a block also raises the threshold, up to
32 MiB, so the server's later image-sized buffers (PIL pixel data, PNG encoding,
whole-file reads in the executor threads) come from per-thread malloc arenas instead.
Those arenas fragment and keep their pages: a server grew from 13 MB to 0.6-1 GB RSS
over one page-workflow shard while its Python heap stayed near 12 MB. Setting
M_MMAP_THRESHOLD fixes the threshold at its start value and turns the raising off.
"""
import ctypes
import sys
M_MMAP_THRESHOLD = -3
MMAP_THRESHOLD_BYTES = 128 * 1024
def pin_mmap_threshold(libc=None) -> bool:
"""Fix glibc's mmap threshold; False where there is no glibc ``mallopt``."""
if not sys.platform.startswith("linux"):
return False
if libc is None:
try:
libc = ctypes.CDLL(None)
except OSError:
return False
mallopt = getattr(libc, "mallopt", None)
if mallopt is None:
return False
return bool(mallopt(M_MMAP_THRESHOLD, MMAP_THRESHOLD_BYTES))
-6
View File
@@ -1,6 +1,5 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
@@ -39,9 +38,6 @@ async function chooseDestination(page) {
test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
const host=await fakeHost(context,page);
// #430: the file is never found again here, so the manual Refresh is what this covers (the found-again path is
// workspace-stale-source.spec.mjs).
await page.route('**/prompt_manager/images/rescope',route=>route.fulfill({status:409,json:{success:false,code:'source-changed',error:'The source of this image changed or was removed.'}}));
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
try {
const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
@@ -253,8 +249,6 @@ test('responsive gallery defers observed size writes and still fits the new view
});}
};
});
// The Grid's observed sizes (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.media-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
+1 -13
View File
@@ -20,7 +20,6 @@ from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.folder_trash import close_trash, replace_folder
from tests.browser.malloc_tuning import pin_mmap_threshold
from tests.browser.request_hold import register_request_holds
from tests.browser import slow_reset_watch
from tests.browser.fast_reset import SEED_ON_LIVE_ENV, ScratchSeeder
@@ -47,7 +46,6 @@ from tests.browser.workspace_library_editor_fixture import (
)
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
from tests.browser.workspace_tagging_fixture import register_tagging_commands
from tests.browser.source_change_fixture import register_source_change_commands
from tests.browser.workspace_review_fixture import register_review_commands
from tests.browser.workspace_collections_fixture import register_collections_commands
from tests.browser.workspace_diagnostics_fixture import (
@@ -221,12 +219,6 @@ def create_app(root: Path) -> web.Application:
PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db")
GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")]
# #480: Delete… never reaches a real system trash from a page test; the fake
# renames into this server's root, which goes when the server exits.
from tests import fake_trash
fake_trash.install(root / "fake-trash")
from py.api import PromptManagerAPI
from py import autotag
from utils.hashing import generate_prompt_hash
@@ -239,8 +231,7 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
restore_sources = register_source_change_commands(routes, api)
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
register_tagging_commands(routes, api, root, model)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
@@ -448,7 +439,6 @@ def create_app(root: Path) -> web.Application:
async def reset(request: web.Request) -> web.Response:
nonlocal history_fixture
request_holds.release()
restore_sources()
# No preview queued by the last test may land in the new fixture.
await api._run_in_executor(api.close_auto_thumbnails)
library_editor_commands.reset()
@@ -756,8 +746,6 @@ def main(argv: list[str] | None = None) -> None:
help="shut down when stdin closes (the launcher's parent-death guard)",
)
args = parser.parse_args(argv)
# Before any thread allocates (#516): large frees then return to the OS.
pin_mmap_threshold()
listener = bind_listener(args.port)
# One machine-readable line tells the launcher which port this process holds.
print(f"{PORT_ANNOUNCEMENT}{listener.getsockname()[1]}", flush=True)
-66
View File
@@ -1,66 +0,0 @@
"""Source changes under an open workspace page (#430), undone by every reset.
- ``POST /__test__/sources/reverse``: the image sources are listed in reverse
order, as System › Sources would renumber them, so every held
``root_scope`` names another index and the page must re-scope its file.
- ``POST /__test__/scope-key/rotate``: the API's scope and cursor keys
change, as when ``workspace-scope.key`` is lost before a restart: a held
scope then cannot be mapped and the page offers Refresh.
"""
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING, Callable
from aiohttp import web
from utils.workspace_keys import WorkspaceKeys
if TYPE_CHECKING:
from py.api import PromptManagerAPI
_CACHE = "_workspace_keys_cache"
def register_source_change_commands(
routes: web.RouteTableDef, api: PromptManagerAPI
) -> Callable[[], None]:
"""Register the commands; returns the restore every reset runs."""
# Imported here: py.config needs the host stubs create_app installs first.
from py.config import GalleryConfig
saved: dict = {}
def restore() -> None:
if "roots" in saved:
GalleryConfig.MONITORING_DIRECTORIES = saved.pop("roots")
api.invalidate_gallery_cache(retire=True)
if "keys" in saved:
keys = saved.pop("keys")
if keys is None:
api.__dict__.pop(_CACHE, None)
else:
api.__dict__[_CACHE] = keys
@routes.post("/__test__/sources/reverse")
async def reverse(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
saved.setdefault("roots", roots)
GalleryConfig.MONITORING_DIRECTORIES = roots[::-1]
await api._run_in_executor(api.invalidate_gallery_cache, True)
return web.json_response({"success": True})
@routes.post("/__test__/scope-key/rotate")
async def rotate(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
saved.setdefault("keys", api.__dict__.get(_CACHE))
api.__dict__[_CACHE] = WorkspaceKeys(
secrets.token_bytes(32), secrets.token_bytes(32), False
)
return web.json_response({"success": True})
return restore
@@ -87,8 +87,8 @@ function measureBackups(mainSelector) {
}
const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
'prompt deletes change records. A restore always keeps one.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
/**
* D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export
+19 -45
View File
@@ -162,6 +162,7 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
@@ -177,8 +178,8 @@ function mapOverviewConcept(reference, overview) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -287,10 +288,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
expect(diffInventory(expected, await measure())).toEqual([]);
});
/**
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
function mapStatisticsConcept(reference, statistics) {
const [runs] = reference.tables;
return {
@@ -298,11 +296,10 @@ function mapStatisticsConcept(reference, statistics) {
// No sample note (prototype copy); a second table follows the first.
body: ['table', 'table'],
tables: [
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)]] },
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
],
};
}
@@ -475,9 +472,9 @@ function measureAppearance(mainSelector) {
}
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
// D156 (#450) moved "Collapse viewer details by default" to the Viewer surface (workspace-viewer-mode.spec.mjs).
const ADDED_APPEARANCE_ROWS = [
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
];
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
@@ -488,11 +485,10 @@ function mapAppearanceConcept(reference, settings) {
control: { ...toggleRow.control, id, checked } }));
return {
...reference,
// D156: the Viewer surface, then (#317) the server's thumbnail settings in their own surface ("Thumbnails",
// README: retained in Appearance).
layout: [reference.layout[0], 'section.surface', 'section.surface', ...reference.layout.slice(1)],
// D97: one added toggle (D156 moved the other) and the "Reset display defaults" operation close the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.operation'] },
// #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance).
layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)],
// D97: two added toggles and the "Reset display defaults" operation close the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
@@ -539,7 +535,7 @@ test('appearance comparison detects mutations', async ({ page, request }) => {
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort())
.toEqual(['layout.1', 'layout.2', 'layout.3', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12',
.toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13',
'rows.length', 'surface.tree'].sort());
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
@@ -618,21 +614,10 @@ const WATCHED = new Set(['watching', 'stopping']);
const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
label: 'Refresh' };
// D158 (#247): the row production adds directly below "Watch for new outputs", in that row's markup.
const FOREIGN_RUNS = { id: 'foreign-runs', label: 'Save prompts from other workflows',
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.' };
/** D158: the concept's "Watch for new outputs" row as the inserted "Save prompts from other workflows" row. */
function foreignRunsRow(monitoringRow, settings) {
return { ...monitoringRow, text: [['label', FOREIGN_RUNS.label, FOREIGN_RUNS.id], ['p', FOREIGN_RUNS.description, null]],
control: { ...monitoringRow.control, id: FOREIGN_RUNS.id, checked: settings.save_foreign_runs } };
}
/** The concept's Sources with the recorded deviations applied, given the server's real settings and sources. */
function mapSourcesConcept(reference, { settings, sources }) {
const [sample] = reference.sources;
const scan = sample.actions[0];
const rowAt = reference.surface.tree.indexOf('div.setting-row');
return {
...reference,
// The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove,
@@ -640,15 +625,13 @@ function mapSourcesConcept(reference, { settings, sources }) {
sources: sources.effective.map(source => ({ ...sample, title: source.label,
description: `${source.path} · ${WATCH_TEXT[source.watch]}`,
actions: [{ ...scan, text: WATCHED.has(source.watch) ? 'Scan changes' : 'Scan folder' }, { ...scan, text: 'Remove' }] })),
rows: reference.rows.flatMap(row => {
if (row.control.id === 'monitoring') return [{ ...row, control: { ...row.control, checked: settings.monitoring_enabled } },
foreignRunsRow(row, settings)];
rows: reference.rows.map(row => {
if (row.control.id === 'monitoring') return { ...row, control: { ...row.control, checked: settings.monitoring_enabled } };
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }];
return { ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } };
}),
// D158: the inserted row after the first setting row; D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row',
...reference.surface.tree.slice(rowAt + 1), 'div.operation'] },
// D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.operation'] },
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
};
@@ -702,12 +685,10 @@ test('sources comparison detects mutations', async ({ page, request }) => {
const expected = mapSourcesConcept(concept, server);
const production = await productionSources(page, 'dark', { notice: true });
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs only in the real sources, the D98 operation, the D158 row (which moves the D113
// range to the third row) and the D113 range.
// Unmapped, the concept differs only in the real sources, the D98 operation and the D113 range.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort())
.toEqual(['operations.2', 'operations.length', 'rows.1', 'rows.2', 'rows.length', 'sources.0', 'sources.1',
'surface.tree'].sort());
.toEqual(['operations.2', 'operations.length', 'rows.1', 'sources.0', 'sources.1', 'surface.tree'].sort());
const measure = () => page.evaluate(measureSources, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed notice and a removed source row.
@@ -721,13 +702,6 @@ test('sources comparison detects mutations', async ({ page, request }) => {
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for outputs'; });
expect(await paths()).toEqual(['rows.0.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for new outputs'; });
// D158: the inserted row is compared too: a missing one and a changed label are caught.
const undoForeign = await mutate(page, { remove: ['#workspace-main .setting-row:has(#foreign-runs)'] });
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
await undoForeign();
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save other prompts'; });
expect(await paths()).toEqual(['rows.1.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save prompts from other workflows'; });
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = 'Add source'; });
expect(await paths()).toEqual(['heading.action.text']);
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = '+ Add source'; });
+2 -5
View File
@@ -231,8 +231,6 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
const status = () => tagDialog(page).locator('#tag-generation-status');
// D147: a state that blocks Save shows its reason beside the actions, not in the status line.
const notice = () => tagDialog(page).locator('.dialog-actions > .tag-action-notice');
// Unavailable model.
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
@@ -271,8 +269,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await saveImageTags(request, path, CONCEPT_TAGS);
await openTagDialog(page, 'production', 'dark');
await save();
await expect(notice()).toHaveText('The file changed since it was previewed. Reopen the Tag dialog.');
await expect(status()).toHaveText('');
await expect(status()).not.toHaveText('');
await shot('production-file-changed');
await page.unroute(writes);
await page.keyboard.press('Escape');
@@ -281,7 +278,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
await openTagDialog(page, 'production', 'dark');
await save();
await expect(notice()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
await shot('production-unknown-outcome');
-40
View File
@@ -52,43 +52,3 @@ export async function unseenRestore(page, restoring) {
open();
}
}
// A restore the open page does see run and finish (#415), driven by the test instead of the 15 s poll's timing. The
// job is shaped as the server's (py/api/backup_restore.py: kind `restore`, one `errors` group, not cancellable).
const RESTORE_JOB_ID = '415415415415415415415415415415ab';
const restoreJob = state => ({ id: RESTORE_JOB_ID, kind: 'restore', title: 'Database restore', state, started_at: 1,
finished_at: state === 'running' ? null : 2, progress: 0, status_line: state === 'running' ? '' : 'Restored the test snapshot.',
processed: 0, total: 0, counters: { errors: 0 }, scope: {}, listed: { errors: 0 }, log_length: 0, cancellable: false });
/** The Activity pill's link while the page follows that restore. */
export const SEEN_RESTORE_PILL = `#/activity/${RESTORE_JOB_ID}`;
/** The toast the page shows when it sees that restore finish (activity-kinds.mjs `restore.finishToast`). */
export const SEEN_RESTORE_TOAST = 'Database restore finished · Restored the test snapshot.';
/**
* Call before the page's first navigation, after `armUnseenRestores(page)` (a later route wins). The page's first
* job-list read lists a restore job running, so its Activity store follows it; its job stream is refused, so the store
* reads the list again, and every later read is held until `finish()`, which answers them with the job finished and
* resolves once one of those answers reached the page. The real restore the test runs meanwhile goes through the API.
*/
export async function followRestore(page) {
let release;
const released = new Promise(resolve => { release = resolve; });
let first = true;
await page.route(`${JOBS}/*/events`, route => route.abort().catch(() => {}));
await page.route(JOBS, async route => {
if (first) {
first = false;
return route.fulfill({ json: { success: true, jobs: [restoreJob('running')] } }).catch(() => {});
}
await released;
// A read the page has aborted since (a newer read supersedes it) can no longer be answered.
return route.fulfill({ json: { success: true, jobs: [restoreJob('finished')] } }).catch(() => {});
});
return {
async finish() {
const answered = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/jobs');
release();
await answered;
},
};
}
@@ -29,19 +29,6 @@ export function pageInventoryOf(main) {
const rows=[...table.querySelectorAll('tbody tr')];
const firstCell=rows[0]?.querySelector('td');
const operations=[...categorySurface.querySelectorAll('.operation')].filter(visible);
const operationOf=row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};};
// D149 (#444): production adds the Untagged prompts row and the tag search and sort above the table; the concept has
// neither. The toolbar is compared on its own (toolbarInventoryOf) with the concept's page toolbar as donor.
const untaggedRow=tagSurface.querySelector(':scope > .operation');
const d149=untaggedRow?{untagged:operationOf(untaggedRow),
// It starts where the concept's table starts: right after the paragraph.
offset:px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top),
// The vertical space the two additions take: the table starts this much lower than after the paragraph alone.
// (Both offsets rounded first, so offset + shift is exactly the table's rounded offset.)
shift:px(wrap.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)-px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)}:null;
return {
heading:{...css(heading,'display','flexDirection','justifyContent','alignItems','columnGap','rowGap','marginBottom'),
h1:{text:h1.textContent,tabIndex:h1.tabIndex,...font(h1)},
@@ -67,26 +54,14 @@ export function pageInventoryOf(main) {
rows:rows.map(row=>{const cells=[...row.querySelectorAll('td')];return {cells:cells.length,height:px(row.getBoundingClientRect().height),tagLines:lines(cells[0]),tag:cells[0].textContent,prompts:cells[1].textContent,
images:shownText(cells[2]),imagesHidden:cells[2].querySelector('.ca-visually-hidden')?.textContent??null,
actions:[...cells[3].querySelectorAll('.row-actions button')].map(node=>node.textContent.trim())};})},
operations:operations.map(operationOf),
d149,
operations:operations.map(row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};}),
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
};
}
/** A `search.toolbar`'s tokens (D149): runs in the page on the concept's page toolbar and on the tag search and sort. */
export function toolbarInventoryOf(root) {
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const px=value=>Math.round(value);
const field=root.querySelector('.search-field'),input=field.querySelector('input'),select=root.querySelector('select');
return {...css(root,'display','columnGap','rowGap','flexWrap','alignItems','marginBottom'),
field:{...css(field,'display','alignItems','columnGap','flexGrow','minWidth','paddingLeft','paddingRight','borderTopWidth','borderTopStyle',
'borderTopColor','borderTopLeftRadius','backgroundColor'),icon:Boolean(field.querySelector(':scope > svg')),height:px(field.getBoundingClientRect().height)},
input:{type:input.type,...css(input,'fontSize','minHeight','backgroundColor','borderTopWidth','color')},
select:{...css(select,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft'),
height:px(select.getBoundingClientRect().height)}};
}
const countOf=(count,noun)=>`${count} ${noun}${count===1?'':'s'}`;
/**
@@ -106,17 +81,12 @@ export function withImageCounts(operations,{images={},only=[]}={}) {
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
// `imageCategories` (#332 D143) is the fixture's own image categories, none by default.
export function mapVocabularyConcept(reference,imageCategories={},{untaggedText=null,shift=0}={}) {
export function mapVocabularyConcept(reference,imageCategories={}) {
const operations=reference.operations.map(row=>({...row,p:{...row.p,
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}}));
// D149 (#444): the Untagged prompts row is drawn as the concept's first category row with its own text and a Browse
// action, and the table moves down by the space it and the tag search and sort take.
const first=reference.operations[0];
const d149=untaggedText===null?null:{untagged:{...first,h3:{...first.h3,text:'Untagged prompts'},p:{...first.p,text:untaggedText}},
offset:reference.table.wrap.offset,shift};
return {...reference,d149,
table:{...reference.table,wrap:{...reference.table.wrap,offset:reference.table.wrap.offset+shift},rows:reference.table.rows.map(row=>({...row,
return {...reference,
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
images:'0'}))},
@@ -124,14 +94,9 @@ export function mapVocabularyConcept(reference,imageCategories={},{untaggedText=
};
}
/** The D149 mapping inputs: the fixture's Untagged prompts count text (none by default) and production's table shift. */
export function d149Of(actual,untaggedText='0 positive prompts') {
return actual.d149?{untaggedText,shift:actual.d149.shift}:{};
}
/** The page comparison: concept rows are the concept's sample tags; production appends fixture-only tags (D4). */
export function compareVocabularyPage(reference,actual,imageCategories={}) {
const expected=mapVocabularyConcept(reference,imageCategories,d149Of(actual));
const expected=mapVocabularyConcept(reference,imageCategories);
const shared=expected.table.rows.length;
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
+6 -389
View File
@@ -5,7 +5,6 @@ import { test, expect } from './fixtures.mjs';
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/collections';
const RESTORE_APPLY_URL = '/prompt_manager/restore/apply';
const main = page => page.locator('#workspace-main');
const heading = page => main(page).getByRole('heading', { level: 1 });
const breadcrumb = page => page.locator('#workspace-breadcrumb');
@@ -306,12 +305,7 @@ test('a finished restore reads the collections again', async ({ page, request })
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
// Wait for the restore job itself, as workspace-system-backups.spec.mjs does: under load a restore outlasts the
// 5 s expect default, and a restore still running after this test replaces the next tests' database (#520).
const applied = page.waitForResponse(response => response.url().endsWith(RESTORE_APPLY_URL));
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
const { job } = await (await applied).json();
await expect(await request.get(`/prompt_manager/jobs/${job.id}/events`)).toBeOK();
await expect(toast(page)).toHaveText(/^Database restore finished · /);
// The list was read again without a reload.
await expect(rows(page)).toHaveCount(2);
@@ -369,26 +363,14 @@ test('a list read superseded by another read still opens a collection made since
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A confirmed rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The
// timer's read supersedes that one while both are held. The rename's answer is held until "Fresh"'s read is, so
// the timer starts only then: with the answer let through at once, a slow host could fire the timer before the
// hash change, and the page would then wait on the timer's read alone (#459).
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await renameHeld;
return route.fallback();
});
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
// supersedes that one while both are held.
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBe(1);
releaseRename();
// The rename landed (the sidebar row has the new name), so the count read is now due.
await expect(row(page, 'Landscape grids')).toHaveCount(1);
await expect.poll(() => hold.held.length).toBe(2);
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
@@ -486,7 +468,7 @@ test('a rename that settles after its dialog closed leaves the next dialog open
});
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
const manifest = await seed(request);
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
@@ -505,372 +487,7 @@ test('a page that says a collection or view could not be opened takes focus afte
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
// An id the seed never made (its one view is manifest.saved_view), whatever ids earlier tests used (#520).
await page.evaluate(id => { window.location.hash = `#/saved/${id}`; }, manifest.saved_view.id + 1000);
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection.
const bar = page => page.locator('#bulkbar > .bulkbar');
const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true });
const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true });
const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true });
const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check();
const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`);
/** Add the checked images under `name` through the bar's dialog. */
async function addChecked(page, name) {
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
await addField(page).fill(name);
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addDialog(page)).toHaveCount(0);
}
test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file);
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Delete…', 'Clear']);
await barButton(page, 'Add to collection').click();
// The concept's simpleInput with Add (D138) and the existing names as suggestions.
await expect(addDialog(page).locator('label')).toHaveText('Collection name');
await expect(addField(page)).toHaveValue('');
await expect(addField(page)).toHaveAttribute('maxlength', '100');
await expect(addDialog(page).locator('datalist option')).toHaveCount(2);
expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value)))
.toEqual(['Experiments', 'Landscapes']);
await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']);
// An empty name keeps the dialog on its field.
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addField(page)).toBeFocused();
await addField(page).fill(' LoRa testing ');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.');
await expect(bar(page).locator('strong')).toHaveText('3 selected');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3');
await expect(rows(page)).toHaveCount(3);
// CUJ 2: one of two already in it.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'loose.png');
await check(page, 'landscape_02.png');
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4');
// CUJ 3: a case variant joins the same collection.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'landscape_03.png');
await addChecked(page, 'lora TESTING');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.');
await expect(rows(page)).toHaveCount(3);
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5');
const created = (await listed(request)).find(item => item.name === 'LoRa testing');
expect(created).toMatchObject({ count: 5, missing: 0 });
// Everything already in it.
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.');
});
test('Remove from collection takes the checked images off the open collection (CUJ 5)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(imageCards(page)).toHaveCount(12);
await check(page, 'landscape_01.png');
await check(page, 'landscape_02.png');
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Delete…', 'Clear']);
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(10);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
// The removed files left the listing, so the bar has nothing checked.
await expect(bar(page)).toHaveCount(0);
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10');
// A checked file still listed stays checked across the reload.
await check(page, 'landscape_03.png');
await check(page, 'landscape_04.png');
await page.route(`**${API}/items`, async route => {
if (route.request().method() !== 'DELETE') return route.fallback();
// Remove only landscape_04; landscape_03 stays listed and checked.
const body = route.request().postDataJSON();
await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) });
});
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(9);
await expect(bar(page).locator('strong')).toHaveText('1 selected');
await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked();
// No file changed: the plain Images page still lists all of them.
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
});
test('Images without a collection filter has no Remove from collection', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await expect(barButton(page, 'Remove from collection')).toHaveCount(0);
// A saved view on a collection has its filter active: Remove shows (design §5).
await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`);
await expect(imageCards(page)).toHaveCount(8);
await check(page, 'experiment_01.png');
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.');
await expect(imageCards(page)).toHaveCount(7);
});
test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } };
await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json',
body: JSON.stringify(reply.body) }));
await check(page, 'loose.png');
await check(page, 'landscape_05.png');
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The images could not be added. Try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
await expect(rows(page)).toHaveCount(2);
reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } };
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } };
const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders'));
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
await reloaded;
await expect(imageCards(page)).toHaveCount(21);
await expect(bar(page)).toHaveCount(0);
});
test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
// Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135).
await page.route('**/prompt_manager/images/output?*', async route => {
// A next page never answers, so the listing stays at its loaded 21 of 900.
if (new URL(route.request().url()).searchParams.has('cursor')) return;
const response = await route.fetch();
const data = await response.json();
await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } });
});
await page.reload();
await expect(imageCards(page)).toHaveCount(21);
await page.locator('#image-grid .image-open').first().focus();
await page.keyboard.press('ControlOrMeta+a');
await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click();
await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected');
await barButton(page, 'Add to collection').click();
await expect(toast(page)).toHaveText('Select at most 500 images.');
await expect(addDialog(page)).toHaveCount(0);
});
for (const theme of ['dark', 'light']) {
test(`the Add to collection dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
const fits = await addDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await addField(page).fill('Phone');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 1 image to “Phone”.');
await expect(row(page, 'Phone')).toBeVisible();
});
}
// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer.
const panel = page => page.locator('#image-inspector');
const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true });
const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true });
const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true });
async function membership(request, path) {
const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images;
const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path });
const response = await request.get(`${API}/membership?${query}`);
await expect(response).toBeOK();
return (await response.json()).collection_ids;
}
test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => {
const manifest = await seed(request);
const { Landscapes, Experiments } = manifest.collections;
await page.goto(`${workspace}#/images`);
// The newest image, landscape_01, is the panel's.
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png');
const open = panel(page).getByRole('button', { name: 'Collections…', exact: true });
// #480 D159: Delete… follows Collections… at the end of the panel's actions.
await expect(panel(page).locator('.detail-footer > button').nth(-2)).toHaveText('Collections…');
await open.click();
await expect(collectionsDialog(page)).toBeVisible();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']);
await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
await choice(page, 'Landscapes').uncheck();
await choice(page, 'Experiments').check();
await newCollection(page).fill('Grids');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Collections updated.');
await expect(open).toBeFocused();
await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9');
const grids = (await listed(request)).find(item => item.name === 'Grids');
expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort());
expect(Landscapes.id).not.toBe(grids.id);
// The Images page lists the same files; the dialog opened again shows the new state.
await expect(imageCards(page)).toHaveCount(21);
await open.click();
await expect(choice(page, 'Grids')).toBeChecked();
await expect(choice(page, 'Experiments')).toBeChecked();
await expect(choice(page, 'Landscapes')).not.toBeChecked();
// Save without a change sends nothing.
let writes = 0;
page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; });
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
expect(writes).toBe(0);
});
test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(imageCards(page)).toHaveCount(12);
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
const viewer = page.locator('#viewer-dialog[open]');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// Review L7: no listing request while the viewer is open.
let listings = 0;
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
page.on('request', countListing);
await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
// Review L6: focus moves to the first checkbox once listed.
await expect(choice(page, 'Experiments')).toBeFocused();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(collectionsDialog(page)).toHaveCount(0);
// The viewer stays on the image; the page keeps listing it while the viewer is open.
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(imageCards(page)).toHaveCount(12);
expect(listings, 'no listing request while the viewer is open').toBe(0);
page.off('request', countListing);
await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(viewer).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(11);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
});
test('a failed membership read says so and Retry reads it again', async ({ page, request }) => {
await seed(request);
let failing = true;
await page.route(`**${API}/membership?*`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.');
await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0);
await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true');
failing = false;
await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
// A failed write keeps the dialog with the current membership.
await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"The collection could not be changed."}' }));
await choice(page, 'Experiments').check();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
});
test('a file in no collection with no collections at all can start one', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.');
await newCollection(page).fill('First');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(rows(page)).toHaveCount(1);
await expect(row(page, 'First').locator('.nav-count')).toHaveText('1');
});
for (const theme of ['dark', 'light']) {
test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
const fits = await collectionsDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
}
// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its
// dialog and message; the listing is read again only once the dialog closes.
test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => {
const manifest = await seed(request);
const { Experiments, Landscapes } = manifest.collections;
// experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'.
const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images;
const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK();
await page.route(`**${API}/items`, route => {
const body = route.request().postDataJSON();
return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback();
});
await page.goto(`${workspace}#/collection/${Experiments.id}`);
await expect(imageCards(page)).toHaveCount(8);
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png');
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await choice(page, 'Experiments').uncheck();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
// The remove from Experiments went through; the dialog shows it and the page has not reloaded under it.
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(imageCards(page)).toHaveCount(8);
await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(7);
await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0);
});
-35
View File
@@ -1,7 +1,6 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {saveImageTags} from './concept-inventory.mjs';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
@@ -496,40 +495,6 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// #449 (D154), #457: History's inline current file draws the file's own image tags in the shared labelled section, named
// with its context so they stay apart from the frozen run's fields. The tags are saved on the real file through the Tag
// routes and come from History's own current-file read (its `image_tags` facet), as Images reads them.
test('the inline current file labels its image tags after its negative prompt',async({page,request})=>{
const reads=[];
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
reads.push(body.image_tags);
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
await route.fulfill({response,json:body});});
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
const showCurrent=async()=>{
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true})).toBeVisible();
};
// An untagged file draws no image tags section.
await showCurrent();await expect(tags).toHaveCount(0);expect(reads.at(-1)).toEqual({state:'none',tags:[]});
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
expect(await saveImageTags(request,'history/current.png',['history_tag','dusk'])).toEqual(['history_tag','dusk']);
await showCurrent();expect(reads.at(-1)).toEqual({state:'current',tags:['history_tag','dusk']});
await expect(tags.locator('.role-label')).toHaveText('Image tags · 2');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag','dusk']);
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
// The viewer opened from History shares the inspection: the same section, plain chips (no Tags filter there, D88).
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
const viewerTags=page.locator('dialog#viewer-dialog #viewer-details section.tag-section.image-tags');
await expect(viewerTags.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerTags.locator('span.chip')).toHaveText(['history_tag','dusk']);await expect(viewerTags.locator('button')).toHaveCount(0);
await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
});
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
// nothing and shows no chip before.
@@ -1,184 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
// #480 (D159): Images › Delete…: the review asks every time (nothing preselected), "Remove from the library only"
// hides the file and keeps it on disk, "Also move the files to the trash" moves it (the server's fake trash here).
test.use({ locale: 'en-US', timezoneId: 'UTC' });
const CARDS = '#workspace-main article[data-image-key]';
const bar = page => page.locator('#workspace-main #bulkbar');
const review = page => page.getByRole('dialog', { name: /^Delete \d+ images?$/ });
const toast = page => page.locator('#workspace-toast');
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConceptAt(page, request);
page.deleteErrors = [];
page.on('pageerror', error => page.deleteErrors.push(error.message));
page.on('dialog', dialog => { page.deleteErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.deleteErrors).toEqual([]);
});
async function openImages(page) {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'images', 'dark');
await expect(page.locator(CARDS).first()).toBeVisible();
return page.locator(CARDS).count();
}
async function check(page, index) {
const card = page.locator(CARDS).nth(index);
const key = await card.getAttribute('data-image-key');
await card.locator('.image-overlays input[type="checkbox"]').check();
return key;
}
async function deleteChecked(page, mode) {
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = review(page);
await expect(dialog).toBeVisible();
const primary = dialog.locator('.dialog-actions button.danger');
// Ask every time: no mode is preselected and the primary waits for one.
await expect(dialog.getByRole('radio')).toHaveCount(2);
for (const radio of await dialog.getByRole('radio').all()) await expect(radio).not.toBeChecked();
await expect(primary).toHaveAttribute('aria-disabled', 'true');
await expect(dialog.locator('dl.meta-grid')).toBeHidden();
const label = mode === 'trash' ? 'Also move the files to the trash' : 'Remove from the library only';
await dialog.getByRole('radio', { name: new RegExp(`^${label}`) }).check();
await expect(dialog.locator('dl.meta-grid')).toBeVisible();
await expect(primary).not.toHaveAttribute('aria-disabled', 'true');
return { dialog, primary };
}
for (const mode of ['library', 'trash']) {
test(`${mode}: the deleted image leaves Images and stays gone after a reload`, async ({ page }) => {
const before = await openImages(page);
const key = await check(page, 0);
const { dialog, primary } = await deleteChecked(page, mode);
await expect(dialog.locator('dt', { hasText: 'Media on disk' })).toBeVisible();
await expect(primary).toHaveText(mode === 'trash' ? 'Move 1 image to the trash' : 'Remove 1 image from the library');
await primary.click();
await expect(toast(page)).toContainText(mode === 'trash' ? 'Moved 1 image to the trash.' : 'Removed 1 image from the library.');
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
await expect(page.locator(CARDS)).toHaveCount(before - 1);
await expect(bar(page)).toBeEmpty();
await page.reload();
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
});
}
// #501: the sidebar's Images count (read once at mount) follows a finished delete without a reload.
test('a finished delete re-reads the sidebar Images count', async ({ page }) => {
await openImages(page);
const badge = page.locator('#primary-nav a[data-route="images"] .nav-count');
await expect(badge).toHaveText(/^\d[\d,]*$/);
const before = Number((await badge.textContent()).replace(/,/g, ''));
await check(page, 0);
const { primary } = await deleteChecked(page, 'library');
await primary.click();
await expect(toast(page)).toContainText('Removed 1 image from the library.');
await expect(badge).toHaveText((before - 1).toLocaleString('en-US'));
});
test('Cancel changes nothing', async ({ page }) => {
const before = await openImages(page);
await check(page, 0);
const { dialog } = await deleteChecked(page, 'library');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.locator(CARDS)).toHaveCount(before);
});
test('switching modes redraws the rows from one review', async ({ page }) => {
await openImages(page);
await check(page, 0);
let previews = 0;
page.on('request', request => { if (request.url().endsWith('/images/delete/preview')) previews += 1; });
const { dialog, primary } = await deleteChecked(page, 'library');
await expect(dialog.locator('dd', { hasText: 'Files stay on disk, hidden from Images.' })).toBeVisible();
await dialog.getByRole('radio', { name: /^Also move the files to the trash/ }).check();
await expect(dialog.locator('dd', { hasText: 'Nothing is deleted permanently' })).toBeVisible();
await expect(primary).toHaveText('Move 1 image to the trash');
expect(previews).toBe(1);
});
test('Delete… in the inspector reviews that one image', async ({ page }) => {
const before = await openImages(page);
const card = page.locator(CARDS).first();
await card.getByRole('button', { name: /^Inspect / }).click();
const button = page.locator('#workspace-main .file-inspector button.danger', { hasText: 'Delete…' });
await expect(button).toBeVisible();
await button.click();
const dialog = review(page);
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await dialog.locator('.dialog-actions button.danger').click();
await expect(page.locator(CARDS)).toHaveCount(before - 1);
});
test('Delete… in the viewer closes it after the delete', async ({ page }) => {
const before = await openImages(page);
await page.locator(CARDS).first().getByRole('button', { name: /^Open / }).click();
const viewer = page.locator('dialog#viewer-dialog[open]');
await expect(viewer).toBeVisible();
await viewer.getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = review(page);
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await dialog.locator('.dialog-actions button.danger').click();
await expect(viewer).toBeHidden();
await expect(page.locator(CARDS)).toHaveCount(before - 1);
});
// The review never starts a delete without an explicit choice (live test 2026-10-29): every activation of the
// aria-disabled primary is ignored, the dialog has no form to submit, and a chosen primary starts once.
test('without a chosen mode nothing can start a delete', async ({ page }) => {
const before = await openImages(page);
await check(page, 0);
const applies = [];
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.postDataJSON()); });
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = review(page);
await expect(dialog).toBeVisible();
const primary = dialog.locator('.dialog-actions button.danger');
await expect(primary).toHaveAttribute('aria-disabled', 'true');
await expect(primary).toHaveText('Choose what happens to the files');
// Visibly dimmed while no mode is chosen.
await expect(primary).toHaveCSS('opacity', '0.55');
await expect(primary).toHaveCSS('cursor', 'not-allowed');
await primary.click({ force: true });
await primary.dblclick({ force: true });
await primary.focus();
await page.keyboard.press('Enter');
await page.keyboard.press('Space');
await dialog.locator('#modal-title').click();
await page.keyboard.press('Enter');
expect(await dialog.locator('form').count()).toBe(0);
await page.evaluate(() => document.querySelector('dialog#modal[open]')?.dispatchEvent(new Event('submit', { bubbles: true })));
await expect(dialog).toBeVisible();
expect(applies).toEqual([]);
for (const radio of await dialog.getByRole('radio').all()) await expect(radio).not.toBeChecked();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator(CARDS)).toHaveCount(before);
expect(applies).toEqual([]);
});
test('a chosen mode starts exactly one delete, with that mode, however often it is pressed', async ({ page }) => {
const before = await openImages(page);
await check(page, 0);
const applies = [];
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.postDataJSON()); });
const { primary } = await deleteChecked(page, 'library');
await primary.dblclick();
await expect(toast(page)).toContainText('Removed 1 image from the library.');
await expect(page.locator(CARDS)).toHaveCount(before - 1);
expect(applies.length).toBe(1);
expect(applies[0].mode).toBe('library');
expect(Object.keys(applies[0]).sort()).toEqual(['mode', 'token']);
});
+5 -67
View File
@@ -1,6 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction, startInGrid } from './concept-inventory.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
@@ -569,10 +569,6 @@ test('source and folder discovery failure stays visible and Import Rescan restor
async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
// #430: the page first asks the server where a stale file is now. These tests cover the manual Refresh, so the
// file is never found; workspace-stale-source.spec.mjs covers a renumbered file that is found again.
await page.route('**/prompt_manager/images/rescope', route => route.fulfill({ status: 409,
json: { success: false, code: 'source-changed', error: 'The source of this image changed or was removed.' } }));
await page.goto(workspace);
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
@@ -814,8 +810,6 @@ test('leaving during Rescan cancels it without replacing the next workspace', as
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
// The approved Grid geometry (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1600,height:1100});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
@@ -897,8 +891,7 @@ test('images toolbar and filter row match the concept controls', async ({ page,
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
// D153 (#447): no "Highest rated", even with a rated prompt in the library.
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Oldest first', 'Name Z–A']);
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
const row = main.locator('.filter-row');
await expect(row.locator(':scope > *')).toHaveCount(5);
await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
@@ -917,8 +910,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await search(page).fill('');
await expect(cards(page)).toHaveCount(2);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
await sortBy(page).selectOption('name-desc');
await expect(sortBy(page)).toHaveValue('name-desc');
await sortBy(page).selectOption('rating');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await sortBy(page).selectOption('newest');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(1);
@@ -1376,61 +1369,6 @@ async function expectStar(button, favorite) {
await expect(button).toHaveAttribute('aria-pressed', String(favorite));
}
// #494: the Favorites count is re-read 300 ms after a star changes. A page that has begun to leave starts no count read
// (WebKit fails it with "Fetch API cannot load … due to access control checks.", reported as a page error; queue run
// 40934368716). A leave that is called off counts again on the next input.
for (const leave of ['beforeunload', 'pagehide']) {
test(`a star changed just before ${leave} starts no Favorites count read while the page leaves`, async ({ page }) => {
await page.clock.install();
const countReads = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === '1' && url.searchParams.get('limit') === '1') countReads.push(url.search);
});
await page.goto(workspace);
const card = page.locator('article[data-image-key="0:shared/same.png"]');
await expectStar(star(card, 'same.png'), false);
await expect.poll(() => countReads.length).toBe(1);
await star(card, 'same.png').click();
await expectStar(star(card, 'same.png'), true);
await expect(toast(page)).toHaveText('Added to favorites.');
await page.evaluate(type => window.dispatchEvent(new Event(type)), leave);
await page.clock.runFor(1000);
// A read made after the clock ran is listed, so the listener saw every count read.
await page.evaluate(() => fetch('/prompt_manager/images/output?mode=workspace&favorites=1&limit=1&probe=1'));
await expect.poll(() => countReads.length).toBe(2);
expect(countReads.at(-1)).toContain('probe=1');
if (leave === 'beforeunload') {
// The leave was called off (the page is still here): the next input counts again, once, and the badge shows it.
await page.keyboard.press('Shift');
await expect.poll(() => countReads.length).toBe(3);
expect(countReads.at(-1)).not.toContain('probe=1');
await expect(page.locator('nav [data-route="favorites"] .nav-count')).toHaveText('1');
await page.clock.runFor(1000);
expect(countReads).toHaveLength(3);
}
});
}
test('a count read in flight when the page starts to leave is dropped, then read again if the page stays (#494)', async ({ page }) => {
const held = [];
// The page's first Library count read is held at the server boundary while the page starts to leave.
await page.route('**/prompt_manager/library?limit=1', route => { held.push(route); });
await page.goto(workspace);
await expect.poll(() => held.length).toBe(1);
const failed = page.waitForEvent('requestfailed', item => item.url().includes('/prompt_manager/library?limit=1'));
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')));
await failed;
await page.unroute('**/prompt_manager/library?limit=1');
await held[0].continue().catch(() => {});
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveCount(0);
// The page stayed: the next input reads the dropped count again.
const reread = page.waitForRequest(item => item.url().includes('/prompt_manager/library?limit=1'));
await page.keyboard.press('Shift');
await reread;
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveText(/^\d+$/);
});
test('favorite persists across reload', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const sent = [];
@@ -1849,7 +1787,7 @@ test('card checkboxes select images into the concept bar and Clear empties it',
await checkbox(first, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Delete…', 'Clear']);
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
// Toggling neither opens the viewer, nor inspects, nor moves focus.
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeFocused();
+9 -277
View File
@@ -8,11 +8,10 @@ import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised,
const workspace = '/prompt_manager/workspace';
const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
const NO_TOAST = '';
const ACTION = 'Open file metadata';
const COPY = {
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
note: 'Your file stays in this browser. Choosing it opens its metadata.',
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
chooser: 'Choose an image or video for a local preview',
multiple: 'Drop one file at a time.',
directory: 'Folders cannot be inspected. Choose one image or video file.',
@@ -54,24 +53,11 @@ async function choose(page, file) {
await chooser(page).setInputFiles(file);
}
/** D155: choosing a file opens the viewer on it with its details; waits for that. */
async function openViewer(page) {
await expect(viewer(page)).toBeVisible();
await expect(rail(page).locator('.panel-heading')).toBeVisible();
}
/** After the viewer was closed, the heading action (D60) opens it again on the current selection. */
async function reopenViewer(page) {
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await action(page).click();
await openViewer(page);
}
/** Waits for the viewer a selection opened (D155) and closes it, back on the page. */
async function closeOpened(page) {
await openViewer(page);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(rail(page).locator('.panel-heading')).toBeVisible();
}
/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
@@ -166,8 +152,7 @@ test('chooser opens the shared full inspector without a second read', async ({ p
const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
expect(box.height).toBeLessThanOrEqual(400);
expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
// D155: the open viewer is the confirmation, so no toast.
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
@@ -210,120 +195,18 @@ test('chooser opens the shared full inspector without a second read', async ({ p
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
// D155: focus returns to the chooser that opened it.
await expect(chooser(page)).toBeFocused();
await expect(action(page)).toBeFocused();
// Opening the viewer again shows the same record without reading the File a second time.
await page.evaluate(() => {
window.__slices = 0;
const slice = Blob.prototype.slice;
Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
});
await reopenViewer(page);
await openViewer(page);
expect(await railContent(rail(page))).toEqual(served);
expect(await page.evaluate(() => window.__slices)).toBe(0);
});
// #448 (D155): choosing or dropping a file opens the shared viewer with its details showing, in one step. Closing it
// returns to the page with the file still chosen and focus on the chooser, ready for the next file.
test('D155: choosing a file opens the viewer with its details in one click', async ({ page }) => {
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(viewer(page)).toBeVisible();
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await expect(rail(page)).toBeVisible();
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
// The open viewer is the confirmation: no toast says the metadata is "available above".
await expect(page.locator('#workspace-toast')).toHaveText('');
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await expect(chooser(page)).toBeFocused();
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
// A dropped second file replaces the first and opens at once.
await drop(page, '.file-drop', { files: [sendablePng()] });
await expect(viewer(page)).toBeVisible();
await expect(viewer(page).locator('#viewer-title')).toHaveText('send-me.png');
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await expect(chooser(page)).toBeFocused();
// The same file again is a new selection and opens again; the heading action reopens the current one.
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
await choose(page, parityPng('f02.png'));
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await page.keyboard.press('Escape');
await action(page).click();
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await expect(rail(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(action(page)).toBeFocused();
});
test('D155: Inspect shows the details even when the viewer collapses them by default (amends D97)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(viewer(page)).toBeVisible();
await expect(rail(page)).toBeVisible();
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(rail(page)).toBeHidden();
await page.keyboard.press('Escape');
await action(page).click();
await expect(rail(page)).toBeVisible();
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
});
test('D155: an unreadable file stays on the page with its alert', async ({ page }) => {
await page.addInitScript(() => {
const slice = Blob.prototype.slice;
Blob.prototype.slice = function (...args) {
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
return slice.apply(this, args);
};
});
await openInspect(page);
await choose(page, parityPng('f02.png', 'gone.png'));
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(viewer(page)).toHaveCount(0);
});
test('D155: an AVI keeps its note and toast and does not open the viewer', async ({ page }) => {
await openInspect(page);
// AVI and WMV metadata is never read and the browser cannot play them, so the page keeps the selection's note.
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
await choose(page, avi);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(viewer(page)).toHaveCount(0);
await reopenViewer(page);
await expect(viewer(page).locator('#viewer-title')).toHaveText('clip.avi');
});
test('D155: a selection that lands while another dialog is open leaves the viewer closed', async ({ page }) => {
await openInspect(page);
await page.evaluate(() => { const other = document.createElement('dialog'); other.id = 'other-dialog'; other.textContent = 'Other'; document.body.append(other); other.showModal(); });
await choose(page, parityPng('f02.png'));
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(viewer(page)).toHaveCount(0);
await page.evaluate(() => document.querySelector('#other-dialog').close());
await reopenViewer(page);
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
});
test('D155: at 390px the opened details are reachable below the media', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(viewer(page)).toBeVisible();
const facts = rail(page).locator('.meta-grid').first();
await facts.scrollIntoViewIfNeeded();
await expect(facts).toBeInViewport();
expect(await viewer(page).evaluate(node => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
});
// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the
// prompts, their status lines and the facts Images shows for the same bytes in the output folder.
const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12',
@@ -377,9 +260,7 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
await expect(preview(page)).toBeEmpty();
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
// D155: the drop opened the viewer; drops are ignored while it is open (D64), so close it first.
await closeOpened(page);
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
const first = await preview(page).locator('img').getAttribute('src');
@@ -394,7 +275,7 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
}
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await reopenViewer(page);
await openViewer(page);
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await expect(page).toHaveURL(/#\/inspect$/);
});
@@ -404,7 +285,6 @@ test('cancel and same-file reselect', async ({ page }) => {
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
await closeOpened(page);
const first = await preview(page).locator('img').getAttribute('src');
// The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
@@ -428,7 +308,6 @@ test('cancel and same-file reselect', async ({ page }) => {
const state = await urls(page);
expect(state.created).toHaveLength(2);
expect(state.revoked).toEqual([first]);
// The new selection opened the viewer again (D155).
await openViewer(page);
await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
});
@@ -458,7 +337,6 @@ test('preview does not imply metadata support', async ({ page }) => {
// Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
const before = (await urls(page)).created.length;
for (const file of disguised()) {
// Unsupported: nothing opens (D155).
await choose(page, file);
await expect(status(page)).toHaveText(COPY.unsupported);
await expect(status(page)).toHaveAttribute('role', 'alert');
@@ -482,7 +360,6 @@ test('local video plays inline and in the viewer with real tags', async ({ page,
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await choose(page, tinyWebm());
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
await openViewer(page);
@@ -509,9 +386,6 @@ test('the toast and a rejected drop never contradict the status line', async ({
// concept's toast interval.
await choose(page, undecodableMp4());
const failed = status(page).filter({ hasText: COPY.previewFailed });
// D155: the selection opened the viewer; its media failed there too, and the page's alert waits under it.
await expect(failed).toHaveCount(1);
await closeOpened(page);
await expect(failed).toHaveCount(1);
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
const rejected = status(page).filter({ hasText: COPY.multiple });
@@ -568,8 +442,7 @@ test('URLs and media end with their owning session', async ({ page }) => {
let state = await urls(page);
expect(state.revoked).toEqual([state.created[0]]);
expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
// Route change, from the page (D155: the selection opened the viewer, which makes the sidebar inert).
await closeOpened(page);
// Route change.
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
state = await urls(page);
@@ -704,147 +577,6 @@ test('a video replaced while still starting is reset once it settles', async ({
}
});
test('a new selection while the viewer\'s own video is still starting leaves it until it settles (#483)', async ({ context, browserName }) => {
test.setTimeout(180000);
// D155 opens the viewer once the inline preview has settled; the viewer then starts a video of its own. A new
// selection from that video's own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it, must not
// reset it or take it off the page while it starts (WebKit, GStreamer: TESTING.md D62; merge-queue run
// 40903238387). The viewer stays open until it reports metadata or an error, then closes; the URL goes last.
for (let attempt = 0; attempt < 10; attempt++) {
const page = await context.newPage();
try {
if (attempt === 0) await startCoverage(page, browserName);
await page.addInitScript(() => {
const load = HTMLMediaElement.prototype.load;
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) { this.__resets = (this.__resets ?? 0) + 1; this.__resetReadyState ??= this.readyState; } return load.call(this); };
const revoke = URL.revokeObjectURL;
URL.revokeObjectURL = url => {
const starting = window.__starting;
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
return revoke(url);
};
});
await openInspect(page);
await page.evaluate(() => {
const input = document.querySelector('#local-file');
new MutationObserver((records, observer) => {
const video = document.querySelector('#viewer-dialog video');
if (!video) return;
observer.disconnect();
const replace = () => {
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), resets: video.__resets ?? 0 };
};
video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true });
}).observe(document.body, { childList: true, subtree: true });
});
const png = parityPng('f02.png');
await page.evaluate(bytes => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
window.__pngFiles = transfer.files;
}, [...png.buffer]);
await page.evaluate(delay => { window.__delay = delay; }, attempt % 5);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
await expect.poll(() => page.evaluate(() => window.__starting?.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 });
const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced }));
if (attempt % 5 === 0) expect(readyState).toBe(0);
// Starting when replaced: still on the page with its URL, never reset; settled: released at once.
expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, resets: 0 } : { connected: false, src: false, resets: 1 });
expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1);
// Its one reset came after it had metadata, never while it was starting.
expect(await page.evaluate(() => window.__starting.node.__resetReadyState)).toBeGreaterThanOrEqual(1);
// The new file's viewer opens after the old one closed, and the page still answers.
await expect(viewer(page).getByRole('img', { name: 'f02.png', exact: true })).toBeVisible({ timeout: 8000 });
await expect(viewer(page).locator('video')).toHaveCount(0);
if (attempt === 0) await saveCoverage(page, browserName);
} finally {
await page.close();
}
}
});
/** #483: holds the viewer's video in HAVE_NOTHING (its `loadedmetadata` never fires) so a hold can be driven by hand. */
async function stallViewerVideo(page) {
await page.addInitScript(() => {
const describe = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'readyState');
Object.defineProperty(HTMLMediaElement.prototype, 'readyState', { configurable: true, get() {
return this.closest?.('#viewer-dialog') && window.__stallViewer ? 0 : describe.get.call(this);
} });
window.addEventListener('loadedmetadata', event => {
if (window.__stallViewer && event.target.closest?.('#viewer-dialog')) event.stopImmediatePropagation();
}, true);
});
}
test('a held viewer closed by hand, or replaced twice, opens only the latest file\'s viewer at once (#483)', async ({ page }) => {
await stallViewerVideo(page);
await openInspect(page);
await page.evaluate(() => { window.__stallViewer = true; });
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
// Two selections while the viewer's video is (made to look) still starting: the viewer stays, untouched.
await chooser(page).setInputFiles(parityPng('f02.png', 'first.png'));
await chooser(page).setInputFiles(parityPng('f02.png', 'second.png'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
await expect(viewer(page).locator('video')).toBeAttached();
// Open file metadata pressed meanwhile (the page behind the viewer is inert, so by script): the latest request.
await action(page).evaluate(node => node.click());
// Closing it by hand ends the hold at once: the latest file's viewer opens, once, well before D62's 15 s.
await page.keyboard.press('Escape');
await expect(viewer(page).getByRole('img', { name: 'second.png', exact: true })).toBeVisible({ timeout: 5000 });
await expect(viewer(page).getByRole('img', { name: 'first.png', exact: true })).toHaveCount(0);
await expect(viewer(page).locator('video')).toHaveCount(0);
await expect(page.locator('dialog[open]')).toHaveCount(1);
});
test('a held viewer revokes its URL only once it is gone, drops a stale open, and ends on page leave (#483)', async ({ page }) => {
await stallViewerVideo(page);
await openInspect(page);
await trackReleases(page);
await page.evaluate(() => { window.__stallViewer = true; });
const videoUrl = () => page.evaluate(() => document.querySelector('#viewer-dialog video')?.getAttribute('src') ?? null);
const released = url => page.evaluate(url => window.__released.filter(item => item.url === url), url);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
const first = await videoUrl();
await chooser(page).setInputFiles(parityPng('f02.png', 'held.png'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
// The session asked to revoke the video's URL; it waits while the held viewer still plays it.
expect(await released(first)).toEqual([]);
// An Open asked for during the hold, then an AVI (which never opens the viewer by itself): the open for the earlier
// file is stale and is dropped when the hold ends.
await action(page).evaluate(node => node.click());
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
await chooser(page).setInputFiles(avi);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
await page.keyboard.press('Escape');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect.poll(() => released(first)).toEqual([{ url: first, live: 0 }]);
// Page leave while held: the viewer goes and the deferred URL is revoked with nothing holding it.
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
const second = await videoUrl();
await chooser(page).setInputFiles(parityPng('f02.png', 'leave.png'));
await expect(viewer(page).locator('video')).toBeAttached();
expect(await released(second)).toEqual([]);
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect.poll(() => released(second)).toEqual([{ url: second, live: 0 }]);
// Another route while held: the page's dispose ends the hold and revokes the URL with nothing holding it.
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(viewer(page).locator('video')).toBeAttached();
const third = await videoUrl();
await chooser(page).setInputFiles(parityPng('f02.png', 'route.png'));
await expect(viewer(page).locator('video')).toBeAttached();
await page.evaluate(() => { window.location.hash = '#/library'; });
await expect(main(page).getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
await expect.poll(() => released(third)).toEqual([{ url: third, live: 0 }]);
});
test('leaving for another route while a video starts waits for it to settle', async ({ context, browserName }) => {
test.setTimeout(180000);
// A WebKit (GStreamer) video detached before its pipeline had started deadlocked the page (TESTING.md D62); a
@@ -1,404 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
// #443 D152: Delete… on the Library bar deletes unpaired prompts, archives paired ones (restorable), keeps protected ones.
const route='/prompt_manager/workspace#/library';
const PREVIEW='/prompt_manager/library/delete/preview';
const DELETE='/prompt_manager/library/delete';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const toast=page=>page.locator('.toast');
const review=page=>page.getByRole('dialog');
const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/});
// #482: on the archived shelf the same button reads the concept's way back (app.js 594 "← Prompt library").
const backToLibrary=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'← Prompt library',exact:true});
async function items(request,shelf='active') {
const query=shelf==='archived'?'&shelf=archived':'';
return (await(await request.get(`/prompt_manager/library?limit=100${query}`)).json()).items;
}
const byText=(list,text)=>list.find(item=>item.text===text);
function recordPosts(page,path) {
const posts=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname===path)posts.push(item.postDataJSON());});
return posts;
}
async function openReview(page) {
await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
await expect(review(page)).toBeVisible();
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:2}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('Delete… reviews the selection, deletes the unpaired, archives the paired and keeps the protected',async({page,request})=>{
const before=await items(request);
const dog=byText(before,'Dog landscape'),cat=byText(before,'Cat portrait'),guarded=byText(before,'Library extra 0000');
await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE);
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
for(const item of [dog,cat,guarded]) await box(page,item.id).check();
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${before.length} loaded`,'Add tags','Set category','Delete…','Clear']);
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).toHaveClass(/danger/);
await openReview(page);
await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts');
const grid=review(page).locator('dl.meta-grid > div');
await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']);
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt in a saved pair: hidden, restorable',
'1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']);
await expect(review(page).locator('p.small')).toHaveCount(3);
await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/);
// Cancel writes nothing and releases the bar.
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(review(page)).toHaveCount(0);expect(writes).toEqual([]);
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
await openReview(page);
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Archived 1 prompt\. 1 protected prompt was kept\. Safety backup saved: \S+\.backup_\S+ \(System › Backup & export\)\.$/);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,dog.id)).toHaveCount(0);await expect(card(page,cat.id)).toHaveCount(0);await expect(card(page,guarded.id)).toHaveCount(1);
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
// One preview per review opened (Cancel, then Delete…).
const reviewed={prompt_ids:[dog.id,cat.id,guarded.id],expected_epoch:epoch};
expect(previews).toEqual([reviewed,reviewed]);
expect(writes).toEqual([{archive_ids:[cat.id],delete_ids:[dog.id],expected_epoch:epoch,backup:'on'}]);
const after=await items(request);
expect(byText(after,'Dog landscape')).toBeUndefined();expect(byText(after,'Cat portrait')).toBeUndefined();
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
});
test('Show archived prompts lists archived prompts, and Restore on its bar returns them',async({page,request})=>{
const before=await items(request);const cat=byText(before,'Cat portrait'),dual=byText(before,'Dual role portrait');
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,cat.id).check();await box(page,dual.id).check();
await openReview(page);
await expect(review(page)).toHaveAccessibleName('Archive selected prompts');
// The archive reason is the saved pair, for one or many prompts (live note on #470): never negative variants.
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText('2 prompts in a saved pair: hidden, restorable');
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 2 prompts.');
await expect(cards(page)).toHaveCount(before.length-2);
// #482: a plain button that names where it goes (no pressed state that its changing label would contradict).
await expect(showArchived(page)).not.toHaveAttribute('aria-pressed');
await showArchived(page).click();
await expect(backToLibrary(page)).toBeVisible();await expect(backToLibrary(page)).not.toHaveAttribute('aria-pressed');
await expect(showArchived(page)).toHaveCount(0);
await expect(cards(page)).toHaveCount(2);
await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/);
await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1);
await box(page,cat.id).check();
await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Clear']);
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
await expect(cards(page)).toHaveCount(1);
await backToLibrary(page).click();
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
await expect(cards(page)).toHaveCount(before.length-1);
await expect(card(page,cat.id)).toHaveCount(1);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
// An empty archived view says so.
await showArchived(page).click();await box(page,dual.id).check();
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
});
// #482 (D154): a prompt in a saved pair can be deleted permanently, with its own saved pairs; Archive stays the default.
const choice=page=>review(page).getByRole('radiogroup',{name:'Prompts in a saved pair'});
const row=(page,term)=>review(page).locator('dl.meta-grid > div').filter({has:page.locator('dt',{hasText:new RegExp(`^${term}$`)})}).locator('dd');
test('Delete permanently removes a paired prompt with its own saved pair; its negative stays (#482)',async({page,request})=>{
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
const writes=recordPosts(page,DELETE);
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,dual.id).check();await openReview(page);
// Archive is checked: the review is #443's.
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
// The D90 radio row (the auto-tag "How to apply"): each radio keeps its own size beside its label.
expect(await choice(page).evaluate(group=>{const label=group.querySelector('label'),radio=label.querySelector('input');
return {group:group.className,display:getComputedStyle(label).display,gap:getComputedStyle(label).columnGap,
margin:getComputedStyle(radio).marginLeft,full:radio.getBoundingClientRect().width>=label.getBoundingClientRect().width/2};}))
.toEqual({group:'field',display:'flex',gap:'8px',margin:'0px',full:false});
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair');
await expect(row(page,'Saved pairs removed')).toHaveText('1 saved pair (1 with a negative prompt)');
await expect(row(page,'Negatives kept')).toHaveText('1 negative prompt is kept');
await expect(row(page,'Backup')).toHaveText('Create and verify before deleting');
await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/);
// Back to Archive: the review is #443's again.
await choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'}).check();
await expect(row(page,'Saved pairs removed')).toHaveCount(0);
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
await expect(card(page,dual.id)).toHaveCount(0);
expect(writes).toHaveLength(1);
expect(writes[0].delete_paired_ids).toEqual([dual.id]);
expect(Object.keys(writes[0].paired_digests)).toEqual([String(dual.id)]);
const after=await items(request);
expect(byText(after,'Dual role portrait')).toBeUndefined();
// Its negative (Cat portrait) is kept, with its own variants.
expect(byText(after,'Cat portrait')?.id).toBe(cat.id);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
});
test('a prompt used as another prompt\'s negative is archived even when Delete permanently is chosen (#482)',async({page,request})=>{
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,cat.id).check();await box(page,dual.id).check();await openReview(page);
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await expect(row(page,'Archive')).toHaveText('1 prompt used as another prompt\'s negative: hidden, restorable');
await expect(review(page).locator('p.small',{hasText:'archived instead of deleted'})).toHaveCount(1);
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Archived 1 prompt\./);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
});
test('the choice resets to Archive on every Delete…, and a selection with no paired prompt has none (#482)',async({page,request})=>{
const before=await items(request);const dual=byText(before,'Dual role portrait'),dog=byText(before,'Dog landscape');
await page.goto(route);await box(page,dual.id).check();await openReview(page);
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await openReview(page);
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
await box(page,dog.id).check();await openReview(page);
await expect(choice(page)).toHaveCount(0);
});
// #481 (D161): a prompt's own page deletes it from Edit metadata (the concept's recipe editor, app.js 976), through the
// same review: "Delete prompt…" beside "Archive variant…" (the variant's archive, renamed so the two cannot be confused).
const editMetadata=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'Edit metadata',exact:true});
const editor=page=>page.locator('dialog.library-editor[open]');
async function deleteFromPage(page,id){
await page.goto(`/prompt_manager/workspace#/library/${id}`);
await editMetadata(page).click();
await expect(editor(page).locator('.dialog-actions button')).toHaveText(['Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
await expect(editor(page).getByRole('button',{name:'Delete prompt…',exact:true})).toHaveClass(/\bdanger\b/);
await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(editor(page)).toHaveCount(0);await expect(review(page)).toBeVisible();
}
test('Delete prompt… on a prompt\'s page archives it by default, and deletes it permanently when chosen (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const previews=recordPosts(page,PREVIEW);
await deleteFromPage(page,dual.id);
expect(previews.map(body=>body.prompt_ids)).toEqual([[dual.id]]);
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 1 prompt.');
// The page stays and shows its Archived notice.
await expect(page.locator('.library-archived-notice h2')).toHaveText('Archived');
await expect(page.locator('#workspace-main h1')).toBeFocused();
await page.locator('.library-archived-notice').getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
// Delete permanently: back to the Prompt library, the prompt gone.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
await expect(page).toHaveURL(/#\/library$/);
await expect(card(page,dual.id)).toHaveCount(0);
expect(byText(await items(request),'Dual role portrait')).toBeUndefined();
});
test('Cancel on the page\'s review changes nothing and returns focus to Edit metadata (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const writes=recordPosts(page,DELETE);
await deleteFromPage(page,dual.id);
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
expect(writes).toEqual([]);
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
});
test('the page\'s delete holds one review at a time, Escape returns focus, and an archived page\'s review is close-only (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const previews=recordPosts(page,PREVIEW);
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
// While the preview is held, Edit metadata is held too: no second review and no save can race the delete.
await expect(editMetadata(page)).toHaveAttribute('aria-disabled','true');
await editMetadata(page).click({force:true});await expect(editor(page)).toHaveCount(0);
release();
await expect(review(page)).toBeVisible();
expect(previews).toHaveLength(1);
expect(previews[0].expected_epoch).toMatch(/\S/);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
await page.unroute(`**${PREVIEW}`);
// Archived: its review has nothing to change and only closes.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(page.locator('.library-archived-notice')).toBeVisible();
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
});
test('a failed review on the prompt\'s page says so, changes nothing and releases Edit metadata (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const writes=recordPosts(page,DELETE);
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
expect(writes).toEqual([]);
await editMetadata(page).click();await expect(editor(page)).toBeVisible();
});
test('a delete answered after the page was left invalidates the shelf without a toast or a jump (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
let release;const held=new Promise(resolve=>{release=resolve;});
let answered;const done=new Promise(resolve=>{answered=resolve;});
await page.route(`**${DELETE}`,async item=>{await held;const response=await item.fetch();await item.fulfill({response});answered();});
await deleteFromPage(page,dual.id);
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await page.evaluate(()=>{window.location.hash='#/overview';});
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// Every toast text shown from here on is recorded, so one shown and hidden again still counts.
await page.evaluate(()=>{window.seenToasts=[];new MutationObserver(()=>{for(const node of document.querySelectorAll('.toast'))
if(node.textContent.trim())window.seenToasts.push(node.textContent.trim());}).observe(document.body,{subtree:true,childList:true,characterData:true});});
release();await done;
// The answer lands on Overview: no navigation; the shelf reads again when opened; and no toast was ever shown.
await page.evaluate(()=>{window.location.hash='#/library';});
await expect(card(page,dual.id)).toHaveCount(0);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
expect(await page.evaluate(()=>window.seenToasts)).toEqual([]);
});
test('a restore since the prompt\'s page loaded is refused and the page reads the Library again (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await armUnseenRestores(page);
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
await expect(page.locator('#workspace-main h1')).toBeFocused();
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
});
test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{
const cat=byText(await items(request),'Cat portrait');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
await expect(await request.post(DELETE,{data:{archive_ids:[cat.id],delete_ids:[],expected_epoch:epoch,backup:'off'}})).toBeOK();
await page.goto(`/prompt_manager/workspace#/library/${cat.id}`);
const notice=page.locator('.library-archived-notice');
await expect(notice.locator('h2')).toHaveText('Archived');
// The notice says why (the saved pair) and counts the prompt's negative variants (live note on #470).
expect(cat.variant_count).toBeGreaterThan(1);
await expect(notice.locator('p').first()).toHaveText(`This prompt is archived because it is in a saved pair. It and its ${cat.variant_count} negative variants are hidden from the Prompt library; nothing was removed.`);
await notice.getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
await expect(notice).toHaveCount(0);
expect(byText(await items(request),'Cat portrait')?.id).toBe(cat.id);
});
// #443: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for
// seconds; a loaded page of prompts is shown without waiting for one, on first load and after a delete.
async function holdAnimationFrames(page) {
await page.addInitScript(()=>{
const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
const held=new Map(),real=new Map();let holding=true,next=0;
window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;};
window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);};
window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();};
});
}
test('a held animation frame does not hide the loaded prompts, on first load or after a delete',async({page,request})=>{
const before=await items(request);const dog=byText(before,'Dog landscape');
await holdAnimationFrames(page);
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,dog.id).check();await openReview(page);
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\./);
await expect(cards(page)).toHaveCount(before.length-1);await expect(card(page,dog.id)).toHaveCount(0);
await page.evaluate(()=>window.__releaseAnimationFrames());
await expect(cards(page)).toHaveCount(before.length-1);
});
test('a double press sends one preview, and the bar holds while it is reviewed',async({page,request})=>{
const dog=byText(await items(request),'Dog landscape');
const previews=recordPosts(page,PREVIEW);
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
await page.goto(route);await box(page,dog.id).check();
const remove=bar(page).getByRole('button',{name:/^(Delete…|Reviewing…)$/});
await remove.click();
await expect(remove).toHaveText('Reviewing…');await expect(remove).toHaveAttribute('aria-disabled','true');
await expect(bar(page).getByRole('button',{name:'Clear',exact:true})).toHaveAttribute('aria-disabled','true');
await remove.click({force:true});
release();
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
expect(previews).toHaveLength(1);
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
});
test('a restore since the page loaded, a failed backup, a busy job and an unconfirmed answer each say what happened',async({page,request})=>{
const before=await items(request);const dog=byText(before,'Dog landscape');
await armUnseenRestores(page);
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
// Failed backup: nothing changed, the selection stays.
await page.route(`**${DELETE}`,item=>item.fulfill({status:500,contentType:'application/json',
body:JSON.stringify({success:false,code:'backup-failed',error:'The safety backup could not be taken and verified; nothing was changed.'})}));
await box(page,dog.id).check();await openReview(page);
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The safety backup failed. The safety backup could not be taken and verified; nothing was changed.');
await expect(box(page,dog.id)).toBeChecked();
// Busy: names the job, nothing changed.
await page.unroute(`**${DELETE}`);
await page.route(`**${DELETE}`,item=>item.fulfill({status:409,contentType:'application/json',
body:JSON.stringify({success:false,code:'busy',error:'busy',running:{id:'abc',title:'Scan'}})}));
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('Scan is running. Nothing was changed; try again when it finishes.');
// Unconfirmed: the shelf reloads.
await page.unroute(`**${DELETE}`);
await page.route(`**${DELETE}`,item=>item.abort());
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await page.unroute(`**${DELETE}`);
// A restore the page has not seen: the review opened before it is refused on the server.
await box(page,dog.id).check();await openReview(page);
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
expect(byText(await items(request),'Dog landscape')).toBeDefined();
});
test('a failed review keeps the selection, and a selection nothing can change opens the close-only review',async({page,request})=>{
const before=await items(request);const guarded=byText(before,'Library extra 0000');
await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,guarded.id).check();
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
await expect(box(page,guarded.id)).toBeChecked();
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
await openReview(page);
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
await expect(review(page).locator('.dialog-actions > button')).toHaveText(['Close']);
await review(page).getByRole('button',{name:'Close',exact:true}).click();
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
expect(byText(await items(request),'Library extra 0000')).toBeDefined();
});
+49 -101
View File
@@ -1,17 +1,16 @@
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions.
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive variant…','Delete prompt…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
const editorDialog=page=>page.locator('dialog.library-editor');
test.beforeEach(async({page,request,browserName})=>{
@@ -27,15 +26,13 @@ async function mountEditor(page,theme) {
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.evaluate(async()=>{
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
window.editorEvents={committed:[],reconciled:[],deletePrompt:[]};
window.editorEvents={committed:[],reconciled:[]};
try {
const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs');
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=createScope();
const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope,
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result),
// As the prompt's page wires it (#481); the review itself is covered in workspace-library-delete.spec.mjs.
onDeletePrompt:request=>window.editorEvents.deletePrompt.push(request.positiveId)});
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)});
window.editorHarness={scope,editor,showWorkspaceToast};
} catch(error) {window.editorHarness={missing:String(error)};}
});
@@ -54,7 +51,7 @@ const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pair
test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible();
expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true);
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
@@ -68,41 +65,36 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
await expect(dialog.getByRole('region')).toHaveCount(0);
await expect(field(dialog,'Category')).toHaveValue('Portraits');
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
await expect(field(dialog,'Rating')).toHaveValue('4');
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
await expect(dialog.getByLabel('Rating')).toHaveCount(0);await expect(dialog.locator('select, #recipe-rating')).toHaveCount(0);
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
await expect(dialog.locator('.demo-note')).toHaveCount(0);
expect(await actionNames(dialog)).toEqual(ACTIONS.metadata);
await expect(button(dialog,'Archive variant…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
// D161 (#481): Delete prompt… closes the editor first and hands the prompt to the page's delete review.
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(button(dialog,'Delete prompt…')).toHaveClass(/\bdanger\b/);await button(dialog,'Delete prompt…').click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(()=>window.editorEvents.deletePrompt)).toEqual([f.positive_id]);expect(page.writes).toHaveLength(0);
});
test('editor modes persist through reload with selected saved negative',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
await field(dialog,'Tags, separated by commas').fill('studio, moody');
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],notes:'Edited notes'});
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const saved=(await detail(request,f.positive_id)).positive;
// D153: the edit never sends a rating, so the stored 4 stays.
expect(f.metadata.rating).toBe(4);expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:4,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
await page.reload();await mountEditor(page);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await button(dialog,'Create text revision').click();
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
@@ -114,15 +106,12 @@ test('editor modes persist through reload with selected saved negative',async({p
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
parent_metadata:{title:'Edited portrait',category:'Uncategorized',notes:'Edited notes'}});
expect(Object.hasOwn(create.body.parent_metadata,'rating'),'D153: a revision sends no rating').toBe(false);
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
// D153: the revision is a new, unrated positive; the revised prompt keeps its stored rating.
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized',rating:null});
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
@@ -131,9 +120,9 @@ test('editor modes persist through reload with selected saved negative',async({p
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
expect(page.writes).toHaveLength(before);
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],notes:''}});
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
@@ -145,19 +134,18 @@ test('editor modes persist through reload with selected saved negative',async({p
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
await expect(page.locator('#editor-trigger')).toBeFocused();
expect((await detail(request,f.positive_id)).positive.rating,'D153: adding a negative keeps the stored rating').toBe(4);
});
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog);
const archive=button(dialog,'Archive variant…');await expect(archive).toBeDisabled();
const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled();
await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.');
await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.');
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();
await button(dialog,'Archive…').click();
await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible();
await expect(button(dialog,'Close dialog')).toBeFocused();
const block=dialog.getByRole('region',{name:'Negative prompt',exact:true});
@@ -170,7 +158,7 @@ test('archive confirms the selected pair with fresh counts and writes only on co
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]);
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
@@ -185,7 +173,7 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog
failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1);
await expect(button(dialog,'Archive')).toBeFocused();
@@ -199,7 +187,7 @@ test('an unconfirmed archive moves focus to Retry instead of dropping it out of
await route.continue();
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.');
await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
@@ -216,7 +204,7 @@ test('an archive conflict offers an explicit Reload that re-reads and never repl
conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.');
await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1);
@@ -244,7 +232,7 @@ test('cancel and busy submit have honest write counts',async({page})=>{
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.');
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape');
await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1);
release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1);
@@ -326,15 +314,15 @@ test('edge states keep drafts, explain disabled archive and clean up on dispose'
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative);
await expect(button(dialog,'Archive variant…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
await expect(button(dialog,'Archive variant…')).toHaveAccessibleDescription('This negative variant is already archived.');
await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.');
await button(dialog,'Cancel').click();
let failed=true;
await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();});
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue('');
failed=false;await button(dialog,'Archive variant…').click();
failed=false;await button(dialog,'Archive…').click();
await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.');
await expect(button(dialog,'Archive')).toBeDisabled();
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
@@ -354,12 +342,12 @@ test('the archive confirmation does not inherit a failed or invalid Edit save me
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.');
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required');
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
expect(page.writes).toHaveLength(1);
});
@@ -466,50 +454,16 @@ async function leaveAndReturn(page,id) {
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
// #415: a restore the page sees finish while New prompt is open keeps the dialog and its entries; the Library is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps New prompt open and reloads the Library after it closes',async({page,request})=>{
const f=page.fixture;const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
const buffer=await snapshot.body();
const current=await detail(request,f.positive_id,'active');
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{title:'Retitled elsewhere'}}})).toBeOK();
const restoreRun=await followRestore(page);
await page.goto(`${workspace}#/library`);const card=page.locator(`article[data-prompt-id="${f.positive_id}"]`);
await expect(card).toContainText('Retitled elsewhere');
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await headingActions(page).getByRole('button',{name:'+ New prompt',exact:true}).click();
const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Typed before restore');await field(dialog,'Positive prompt').fill('A prompt typed while storage was restored');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
page.writes.length=0;
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(dialog).toBeVisible();
await expect(field(dialog,'Title')).toHaveValue('Typed before restore');
await expect(field(dialog,'Positive prompt')).toHaveValue('A prompt typed while storage was restored');
await expect(card).toContainText('Retitled elsewhere');
await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1);
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(card).not.toContainText('Retitled elsewhere');await expect(card).toContainText('Editor portrait');
await expect(page.locator('#workspace-main h1')).toBeFocused();
expect(page.writes).toHaveLength(1);
});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
// D152 (#443): Show archived prompts (N) comes first, a plain button (#482: no pressed state; on the archived shelf it
// reads "← Prompt library").
expect(await visibleNames(actions)).toEqual(['Show archived prompts (0)','Export','+ New prompt']);
await expect(actions.nth(0)).not.toHaveAttribute('aria-pressed');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
// One image is counted in the singular.
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
await expect(actions.nth(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/);
await expect(actions.nth(2)).toHaveClass(/\bca-primary\b/);
for(const index of [0,1,2])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
await actions.nth(2).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0];
@@ -643,20 +597,16 @@ test('restored storage after a confirmed save or before an archive offers an exp
const response=await route.fetch();await expect(await restore()).toBeOK();return route.fulfill({response});
});
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Notes').fill('Lost to a restore');
// The save's answer waits for a whole restore in the route above; wait for that answer, not the 5 s expect
// default (#501 merge queue: under load the restore outlasted it on WebKit).
const saved=page.waitForResponse(item=>item.request().method()==='PATCH'&&new URL(item.url()).pathname===`/prompt_manager/library/${f.positive_id}`);
await button(dialog,'Save').click();await saved;
await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await expect(await restore()).toBeOK();await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1);
@@ -673,10 +623,10 @@ test('restored storage before an Edit or Add negative save offers only the Libra
await expect(await restore()).toBeOK();
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
// Revise and archive would re-read the old numeric IDs, which may now name another prompt.
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
await expect(button(dialog,'Reload Library')).toBeFocused();
expect(page.writes).toHaveLength(1);// the refused save
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
@@ -743,7 +693,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
const before=await evidence();expect(before.counts).toEqual({images:1,runs:2});
await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
@@ -823,20 +773,20 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await uniqueDotNames(page);
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page,request})=>{
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
const f=page.fixture;await page.goto(`${workspace}#/library`);
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(UNAPPLIED);
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
@@ -844,8 +794,6 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
expect(page.writes).toHaveLength(1);
// D153: neither the reuse nor the kept draft touches the stored rating.
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
});
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
@@ -853,7 +801,7 @@ test('reuse with the stored parent metadata entered does not claim it was not ap
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
@@ -869,13 +817,13 @@ test('reuse of an uncategorized prompt with the category left blank is not unapp
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
};
await reuse('','blank category negative');
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
await reuse('Landscapes','different category negative');
await expect(toast(page)).toHaveText(UNAPPLIED);
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
});
@@ -886,7 +834,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
@@ -1,143 +0,0 @@
// #444 (D150): the Library's output-folder filter and multi-tag OR in "Refine this view", against the real server, and
// the Refine dialog against the concept's Filters dialog (app.js:1345-1352).
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,diffInventory} from './library-editor-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/library';
const concept='/__test__/canonical/workspace';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,text)=>cards(page).filter({has:page.getByText(text,{exact:true})});
const toolbar=page=>page.locator('#workspace-main search.toolbar');
const filtersButton=page=>toolbar(page).getByRole('button',{name:/^Filters(?: · \d)?$/});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
const folder=page=>refine(page).getByRole('combobox',{name:'Folder',exact:true});
const tags=page=>refine(page).getByRole('textbox',{name:'Tags',exact:true});
const match=page=>refine(page).getByRole('combobox',{name:'Tag match',exact:true});
const apply=page=>refine(page).getByRole('button',{name:'Apply filters',exact:true});
const caption=page=>page.locator('#workspace-main .section-caption > span').first();
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
async function open(page) {
await page.goto(route);await expect(caption(page)).toHaveText(/^3 positive prompts · /);
}
/** Open Refine and wait for the folder list it reads on opening. */
async function openRefine(page) {
const folders=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/library/options'&&new URL(item.url()).searchParams.get('include')==='folders');
await filtersButton(page).click();await expect(refine(page)).toBeVisible();await folders;
}
test('Refine offers Folder, Tags and Tag match; a folder narrows the shelf to prompts with images there',async({page})=>{
await open(page);await openRefine(page);
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);
// Both roots hold a "shared" folder; the root's own images name no folder.
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);
await expect(match(page).locator('option')).toHaveText(['All of these tags','Any of these tags','Prompts without tags']);
await folder(page).selectOption('shared');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
await expect(cards(page)).toHaveCount(1);await expect(card(page,'Cat portrait')).toHaveCount(1);
await expect(filtersButton(page)).toHaveText('Filters · 1');
await openRefine(page);await expect(folder(page)).toHaveValue('shared');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();
await expect(caption(page)).toHaveText(/^3 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters');
});
test('Tag match Any shows every prompt carrying one of the tags; changing only the match applies it',async({page})=>{
await open(page);await openRefine(page);
await tags(page).fill('cats, dogs');await apply(page).click();
await expect(caption(page)).toHaveText(/^0 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
await openRefine(page);await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^2 positive prompts · /);
await expect(cards(page)).toHaveCount(2);await expect(card(page,'Cat portrait')).toHaveCount(1);await expect(card(page,'Dog landscape')).toHaveCount(1);
await expect(filtersButton(page)).toHaveText('Filters · 2');
});
test('a saved view keeps its folder and tag match',async({page})=>{
await open(page);await openRefine(page);
await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
await toolbar(page).getByRole('button',{name:'Save view',exact:true}).click();
const save=page.getByRole('dialog',{name:'Save this view',exact:true});
await save.getByRole('textbox',{name:'View name',exact:true}).fill('Shared cats or dogs');
const created=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/saved-views'&&item.request().method()==='POST');
await save.getByRole('button',{name:'Save',exact:true}).click();
const body=(await created).request().postDataJSON();
expect(body.state).toMatchObject({folder:'shared',tag_mode:'any',tags:['cats','dogs']});
await page.goto('about:blank');await open(page);
await page.getByRole('navigation',{name:'Saved views',exact:true}).getByRole('link',{name:'Shared cats or dogs',exact:true}).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 3');
});
test('a chosen folder missing from the folder list stays listed and chosen in Refine',async({page})=>{
await open(page);await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
// The folder has no image any more as far as the list knows (or the read failed): Apply must not drop it.
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders:[]}}));
await openRefine(page);
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);await expect(folder(page)).toHaveValue('shared');
await apply(page).click();await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
});
// Live B1: the server's flat list sorts full paths as text ("-" and "_" before "/"); Refine shows each folder directly
// under its own parent and keeps case variants apart.
test('the Folder list nests each folder under its exact parent',async({page})=>{
const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',
'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','kiko','kiko-v6','kiko/char'].sort();
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders}}));
await open(page);await openRefine(page);
const I='\u00a0\u00a0';
await expect(folder(page).locator('option')).toHaveText(['All output folders','CCC',`${I}mabes9sans-dataset`,`${I}${I}mabes9sans-dataset_IMG_GEN`,
`${I}mabes9sans-dataset-v1`,'FLUX',`${I}2025-12-11`,'flux',`${I}images`,'flux-klein','kiko',`${I}char`,'kiko-v6']);
expect(await folder(page).locator('option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(['','CCC','CCC/mabes9sans-dataset',
'CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN','CCC/mabes9sans-dataset-v1','FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein',
'kiko','kiko/char','kiko-v6']);
});
test('an older server that ignores the folder shows an error with Retry, never the whole shelf',async({page})=>{
await open(page);
await page.route('**/prompt_manager/library?*',async route=>{
const response=await route.fetch();const body=await response.json();delete body.folder;delete body.tag_mode;
await route.fulfill({response,json:body});
});
await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
await expect(page.locator('#workspace-main').getByText('Prompt library could not be loaded. Retry.')).toBeVisible();
await expect(cards(page)).toHaveCount(0);
});
// D150: the Refine dialog is the concept's Filters dialog: its Folder select and Tags input, plus Tag match drawn as the
// concept's own select; the concept's "Prompt evidence" (no data yet) and its prototype note are not shown.
function mapFiltersDialog(reference,{folders,matchOptions}) {
const [conceptFolder,,conceptTags]=reference.fields;
return {...reference,
fields:[{...conceptFolder,options:folders},{...conceptTags},{...conceptFolder,label:'Tag match',options:matchOptions}],
small:[],
// Production's Apply reads "Apply filters" (Images and Library share it, #249).
actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Apply tag filter'?{...item,text:'Apply filters'}:item)}};
}
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`Refine dialog matches the concept Filters dialog ${width} ${theme} (D150)`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
await page.goto('about:blank');await page.goto(`${concept}#library`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
const reference=await page.locator('#modal').evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`concept-filters-${width}-${theme}`,'#modal');
await open(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openRefine(page);
await expect(folder(page).locator('option')).toHaveCount(2);
const actual=await refine(page).evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`production-refine-${width}-${theme}`,'dialog[open]');
const expected=mapFiltersDialog(reference,{folders:[['','All output folders'],['shared','shared']],matchOptions:[['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]});
const path=testInfo.outputPath('refine-inventory.json');await writeFile(path,JSON.stringify({reference,actual},null,2));
await testInfo.attach('refine-inventory',{path,contentType:'application/json'});
expect(reference.fields.map(item=>item.label)).toEqual(['Folder','Prompt evidence','Tags']);
expect(diffInventory(expected,actual)).toEqual([]);
await refine(page).getByRole('button',{name:'Close dialog',exact:true}).click();
await openRefine(page);await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await page.mouse.move(0,0);
await captureCanonicalState(page,testInfo,`production-library-folder-any-${width}-${theme}`,'#workspace-main');
});
@@ -1,295 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
// #437 D148: select prompts on the Library shelf and set their category in one step (the legacy admin's Set Category).
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true});
const toast=page=>page.locator('.toast');
const heading=page=>page.locator('#workspace-main h1');
const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true});
const chips=(page,id)=>card(page,id).locator('.tags > .chip');
async function addTags(page,value) {
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
await tagsDialog(page).locator('#action-input').fill(value);
await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
async function shelf(request) {
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
return body.items;
}
/** Every POST to the bulk category route, with its body. */
function recordWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/category')writes.push(item.postDataJSON());});
return writes;
}
/** The first mounted card's index once two frames in a row agree (the shelf's window follows scrolling a frame later). */
async function stableFirstIndex(page) {
return page.evaluate(()=>new Promise(resolve=>{
const first=()=>document.querySelector('article[data-prompt-id]')?.dataset.index;
let previous=first();
const check=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{const now=first();if(now===previous)resolve(Number(now));else{previous=now;check();}}));
check();
}));
}
async function setCategory(page,value) {
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page)).toBeVisible();
await dialog(page).locator('#action-input').fill(value);
await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('Set category writes the selected prompts on the server, reloads the shelf and clears the selection',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// The checkbox is named after the card's title, as the Images overlay names its file.
await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `));
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
// D152 (#443) adds Delete… before Clear.
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Delete…','Clear']);
await setCategory(page,' Studio set ');
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) await expect(card(page,item.id).locator('.card-title .chip')).toHaveText('Studio set');
await expect(card(page,third.id).locator('.card-title .chip')).toHaveText(third.category || 'Uncategorized');
await expect(box(page,first.id)).not.toBeChecked();
const after=await shelf(request);
expect(after.filter(item=>item.category==='Studio set').map(item=>item.id).sort()).toEqual([first.id,second.id].sort());
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],category:'Studio set',expected_epoch:epoch}]);
// The toolbar's category list now offers the new value, and so does the next dialog.
await expect(page.locator('#category-filter option',{hasText:'Studio set'})).toHaveCount(1);
await box(page,third.id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page).locator('datalist option[value="Studio set"]')).toHaveCount(1);
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(1);
});
test('a blank or control-character category keeps the dialog open; Clear and Escape clear the selection',async({page,request})=>{
const items=await shelf(request);const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
const field=dialog(page).locator('#action-input');
await field.fill(' ');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();await expect(field).toBeFocused();
await field.fill('bad\tvalue');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe('A category cannot contain control characters.');
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(0);
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
await expect(box(page,items[0].id)).not.toBeChecked();
await box(page,items[1].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
// A filter change replaces the shelf and clears the selection too.
await box(page,items[1].id).check();await expect(bar(page)).toBeVisible();
await page.locator('#search').fill('portrait');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
test('Shift+click selects a range across windowed cards, Tab reaches the checkbox at the window edge, and a return keeps the fence',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
// Windowed: fewer cards are mounted than are loaded.
expect(await cards(page).count()).toBeLessThan(160);
// Tab from the last mounted card's last control lands on the next (unmounted) card's checkbox.
const last=cards(page).last();const lastIndex=Number(await last.getAttribute('data-index'));
await last.locator('button').last().focus();await page.keyboard.press('Tab');
const next=page.locator(`article[data-index="${lastIndex+1}"]`);
await expect(next.locator('.image-overlays input')).toBeFocused();
// Shift+click that card's checkbox: every card from the first to it is selected, mounted or not.
await next.locator('.image-overlays > label').click({modifiers:['Shift']});
await expect(bar(page).locator('strong')).toHaveText(`${lastIndex+2} selected`);
// Shift+Tab from a checkbox whose previous card is unmounted lands on that card's last control.
// Halfway down the loaded cards (short of the continuation, so no page loads), once the window stops moving.
await page.evaluate(()=>{const grid=document.querySelector('.library-grid');scrollTo(0,grid.getBoundingClientRect().top+scrollY+grid.offsetHeight/2);});
await expect.poll(()=>page.locator('article[data-index="0"]').count()).toBe(0);
const topIndex=await stableFirstIndex(page);
await page.locator(`article[data-index="${topIndex}"] .image-overlays input`).focus();await page.keyboard.press('Shift+Tab');
await expect(page.locator(`article[data-index="${topIndex-1}"] .card-footer .row-actions > button`).last()).toBeFocused();
// Leaving for a prompt's history and coming back restores the shelf without a read; its epoch still fences.
await page.locator(`article[data-index="${topIndex}"]`).getByRole('button',{name:'Explore history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(cards(page).first()).toBeVisible();await expect(page.locator('#library-bulkbar')).toBeEmpty();
const visible=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,visible).check();await setCategory(page,'Returned');
await expect(toast(page)).toHaveText('Category “Returned” set on 1 prompt.');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[visible],category:'Returned',expected_epoch:epoch}]);
});
test('a restore the page did not see refuses the write, changes nothing and reloads the shelf',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
const reload=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor');});
await setCategory(page,'After restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No category was changed.');
await reload;await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await shelf(request)).filter(item=>item.category==='After restore')).toEqual([]);
// The reloaded shelf reads the new epoch: the next write goes through.
await box(page,items[0].id).check();await setCategory(page,'After reload');
await expect(toast(page)).toHaveText('Category “After reload” set on 1 prompt.');
});
test('an answer that may follow a committed write says so and reloads; a refused write keeps the selection',async({page,request})=>{
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await page.route('**/prompt_manager/bulk/category',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
await setCategory(page,'Refused');
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
await expect(bar(page).locator('strong')).toHaveText('1 selected');
let sent=false;
await page.route('**/prompt_manager/bulk/category',async route=>{sent=true;await route.fetch();await route.abort('failed');},{times:1});
await setCategory(page,'Unconfirmed');
await expect(toast(page)).toHaveText('The category change could not be confirmed. The Library was reloaded.');
expect(sent).toBe(true);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
});
// #442: Add tags writes prompt tags (never image tags) on the selected prompts.
function recordTagWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());});
return writes;
}
async function promptTags(request,id) {
return (await shelf(request)).find(item=>item.id===id).tags;
}
test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page);
const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags;
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,first.id).check();await box(page,second.id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length);
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0);
await addTags(page,' studio-lit , Night ,studio-lit');
await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) {
await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1);
expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night']));
}
expect(await promptTags(request,third.id)).not.toContain('studio-lit');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]);
// A second add of the same names changes nothing and says so.
await box(page,first.id).check();await addTags(page,'Studio-Lit');
await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.');
});
test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
const field=tagsDialog(page).locator('#action-input');
for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'],
[Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) {
await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe(message);
}
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();
expect(writes).toHaveLength(0);
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
await addTags(page,'after-restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore');
});
// #444 ruling (D151): select every loaded prompt, as the legacy page's select-all checkbox did for its page.
test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const loaded=160;expect(await cards(page).count()).toBeLessThan(loaded);
// In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt.
await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action.
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true});
await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`);
await offer.click();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
await expect(offer).toHaveCount(0);
await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused();
// Another batch arrives unchecked: the offer comes back with the new N.
{
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
// Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again.
const title=cards(page).first().locator('.card-title > button');
await title.focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused();
await page.keyboard.press('ControlOrMeta+a');
const all=loaded+40;
await expect(bar(page).locator('strong')).toHaveText(`${all} selected`);
// While the write runs, every bar action and the offer are aria-disabled.
await box(page,firstId).uncheck();
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
await addTags(page,'everyone');
for(const name of ['Add tags','Set category','Delete…','Clear',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
release();
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
});
+12 -47
View File
@@ -57,26 +57,6 @@ test('library groups reusable negatives under one positive without exposing nega
for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
});
// #464: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for seconds;
// a loaded page of prompts is shown without waiting for one.
test('a held animation frame does not hide a loaded page of prompts, the first or a later one',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:45}})).toBeOK();
const {total}=await(await request.get('/prompt_manager/library?limit=100')).json();
await page.addInitScript(()=>{
const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
const held=new Map(),real=new Map();let holding=true,next=0;
window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;};
window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);};
window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();};
});
await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
await expect(more).toHaveAttribute('aria-disabled','false');await expect(cards(page)).toHaveCount(40);
await more.evaluate(node=>node.click());await expect(page.locator('.library-status')).toHaveText('You’re all caught up.');
await expect(cards(page)).toHaveCount(total);
await page.evaluate(()=>window.__releaseAnimationFrames());await nextFrame(page);
await expect(cards(page)).toHaveCount(total);await expect(page.locator('.library-row-spacer')).toHaveCount(0);
});
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
@@ -155,8 +135,7 @@ test('Retry re-reads a failed category list with the shelf',async({page})=>{
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
// Only the folder list Refine reads on opening (D150), never the shelf.
await page.waitForTimeout(400);expect(reads).toEqual(['/prompt_manager/library/options']);await expect(cards(page)).toHaveCount(3);
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
@@ -173,25 +152,13 @@ test('failed library reads retry while late search responses cannot overwrite ne
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
await expect(cards(page)).toHaveCount(3);
await expect(page.getByRole('combobox',{name:'Sort results',exact:true})).toHaveValue('newest');
expect(sorts).toEqual(['newest']);
});
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
// D153 (#447): no "Highest rated".
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
@@ -201,7 +168,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
@@ -272,8 +239,7 @@ test('a sort change atomically discards old row geometry and renders changed par
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
await expect(cards(page)).toHaveCount(3);
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
// D153: a stored rating of 5 still draws no stars.
await expect(changed.locator('.card-footer > span')).not.toContainText('★');await expect(changed.locator('.card-footer > span > *')).toHaveCount(0);
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
@@ -479,8 +445,8 @@ test('Library cards clamp long prompts and keep real content inside the card',as
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
const footer=card.locator('.card-footer > span');
// D153: no stars or "Rated N out of 5", whatever the stored rating.
await expect(footer.locator('> *')).toHaveCount(0);
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
}
@@ -632,7 +598,7 @@ async function patchShelf(page,patch) {
await route.fulfill({response,json:body});
});
}
/** The footer line as seen: everything but visually hidden text (D153: there is no rating sentence any more). */
/** The footer line as seen: everything but its visually hidden rating sentence. */
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
@@ -652,8 +618,7 @@ test('card matches the concept and never wraps the category mid-word',async({pag
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
await expect(card).toHaveClass('prompt-card');
// D148 (#437): the concept's Images selection overlay leads the card.
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['image-overlays','card-art','card-content','card-footer']);
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
@@ -668,13 +633,13 @@ test('card matches the concept and never wraps the category mid-word',async({pag
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
const footer=card.locator('.card-footer');
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
// D153 (#447): the stored rating 5 draws no stars; the footer reads only the last use.
await expect(footer.locator('> span > *')).toHaveCount(0);
expect(await footer.locator('> span').evaluate(shownText)).toBe('Used today');
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
// Never used: the used label alone; no negatives still reads as a count.
// Unrated and never used: the used label alone; no negatives still reads as a count.
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
-27
View File
@@ -221,33 +221,6 @@ test('tag image from review saves tags and the item remains pending', async ({ p
await expect(dialog).toHaveCount(0);
});
test('#515: tags typed before the image observation answers are saved', async ({ page, request }) => {
await seed(request, 1);
await open(page);
// Hold the dialog's observation so the typing lands while it is pending (a slow host or WebKit in CI).
let release;
const held = new Promise(resolve => { release = resolve; });
const matcher = '**/prompt_manager/image-annotations/observe';
let reachedObserve;
const reached = new Promise(resolve => { reachedObserve = resolve; });
await page.route(matcher, async route => { reachedObserve(); await held; await route.continue(); });
await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Tag image', exact: true });
await reached;
const field = dialog.getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
await field.fill('dunes');
const save = dialog.getByRole('button', { name: 'Save manual tags', exact: true });
await expect(save).toHaveAttribute('aria-disabled', 'true');
const answered = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
release();
await answered;
await expect(save).not.toHaveAttribute('aria-disabled', 'true');
await expect(field).toHaveValue('dunes');
await save.click();
await expect(toast(page)).toHaveText('1 tags saved for review_0000.png. Prompt association still awaits review.');
await page.unroute(matcher);
});
test('an empty queue shows the empty notice only', async ({ page, request }) => {
await seed(request, 0);
await page.goto(review);
+5 -30
View File
@@ -208,7 +208,7 @@ test('a view stored under older rules still lists and opens with its usable valu
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
]);
// A state that is no JSON object is never listed, and its link behaves like a missing view.
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
@@ -230,34 +230,9 @@ test('a view stored under older rules still lists and opens with its usable valu
await expect(promptCards(page)).toHaveCount(1);
});
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (['/prompt_manager/images/output', '/prompt_manager/library'].includes(url.pathname)) listings.push(url.searchParams.get('sort'));
});
await page.goto(`${workspace}#/saved/${images.id}`);
await expect(heading(page)).toHaveText('Rated cats');
await expect(sortBy(page)).toHaveValue('newest');
expect(await sortBy(page).locator('option').allTextContents()).not.toContain('Highest rated');
await expect(search(page)).toHaveValue('cat');
await expect(imageCards(page)).toHaveCount(1);
await savedNav(page).getByRole('link', { name: 'Rated dogs', exact: true }).click();
await expect(heading(page)).toHaveText('Rated dogs');
await expect(sortBy(page)).toHaveValue('newest');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
expect(listings.length).toBeGreaterThan(1);
expect(listings).not.toContain('rating');
});
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/images`);
await search(page).fill('cat');
await expect(imageCards(page)).toHaveCount(1);
@@ -300,7 +275,7 @@ test('a toolbar change while sources load keeps the saved Source and Folder (#24
});
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let releaseList = () => {};
const holdList = async () => {
const held = new Promise(resolve => { releaseList = resolve; });
@@ -348,7 +323,7 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as
await expect(page).toHaveURL(/#\/images$/);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let failing = true;
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
@@ -539,7 +514,7 @@ test('Edit renames an open view, and deletes it only after confirming inside the
});
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
const edit = main(page).locator('.heading-actions > button').first();
@@ -1,245 +0,0 @@
// #430: an open workspace page whose sources changed under it. A ComfyUI restart no longer changes any scope (the
// key is persisted, see tests/test_api_workspace_restart.py); what is left is a real source change. Renumbered
// sources (`/__test__/sources/reverse`) are found again: the Tag image dialog stays open on its file and works, and
// the panel keeps the file. A scope that cannot be mapped (`/__test__/scope-key/rotate`, a lost key) blocks Generate
// and Save visibly, says why beside them and offers Refresh. Both commands are undone by every reset.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const images = '/prompt_manager/workspace#/images';
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png';
const SOURCE_CHANGED = 'The source changed. Refresh the image collection before tagging this file.';
const card = (page, path, root = 0) => page.locator(`article[data-image-key="${root}:${path}"]`);
const cards = page => page.locator('.gallery-body article[data-image-key]');
const tagButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
const infoButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const primary = page => dialog(page).locator('.dialog-actions .ca-primary');
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const status = page => dialog(page).locator('#tag-generation-status');
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const toast = page => page.locator('#workspace-toast');
const answered = (page, suffix, method = 'POST') => page.waitForResponse(item => new URL(item.url()).pathname.endsWith(suffix) && item.request().method() === method);
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
page.staleErrors = [];
page.on('pageerror', error => page.staleErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
expect(page.staleErrors).toEqual([]);
// The next spec file starts on the standard sources and key.
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
});
async function openSettled(page, url) {
// The page reads its selected file's details as soon as it lists. A source change before that read is answered
// makes the page find the file again on its own, so the test's old-numbering locators are gone before they are
// used (#484): every test changes sources only after the read was answered.
const inspected = page.waitForResponse(item => new URL(item.url()).pathname.startsWith('/prompt_manager/images/inspect/') &&
item.request().method() === 'GET');
await page.goto(url);
expect((await inspected).status()).toBe(200);
}
async function openTag(page, path = FIRST, root = 0) {
const observed = answered(page, '/image-annotations/observe');
await tagButton(page, path, root).click();
await expect(dialog(page)).toBeVisible();
return observed;
}
async function savedTags(request, path, root) {
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const item = listing.images.find(image => image.path === path && image.root_index === root);
const target = { root_index: root, root_scope: item.root_scope, path };
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
return observed.tags;
}
test('renumbered sources under an open Tag dialog: it finds the file again, keeps the draft and Generate works', async ({ page, request }) => {
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
expect((await openTag(page)).status()).toBe(200);
await manual(page).fill('kept_draft');
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
// Generate is refused (the held scope names another index now); the page asks where the file is and retargets.
const refused = answered(page, '/image-annotations/suggest');
const rescoped = answered(page, '/images/rescope');
const observedAgain = answered(page, '/image-annotations/observe');
await generate(page).click();
expect((await refused).status()).toBe(409);
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
const observed = await observedAgain;
expect(observed.status()).toBe(200);
expect(observed.request().postDataJSON().target).toEqual({ root_index: 1, root_scope: mapped.image.root_scope, path: FIRST });
await expect(dialog(page)).toBeVisible();
await expect(notice(page)).toHaveCount(0);
await expect(manual(page)).toHaveValue('kept_draft');
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
// The page listed again under the new numbering and kept the file selected.
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
await expect(card(page, FIRST, 0)).toHaveCount(0);
const suggested = answered(page, '/image-annotations/suggest');
await generate(page).click();
expect((await suggested).status()).toBe(200);
await expect(chips(page).first()).toBeVisible();
const saved = answered(page, '/image-annotations/tags', 'PUT');
await primary(page).click();
expect((await saved).request().postDataJSON().target.root_index).toBe(1);
await expect(toast(page)).toContainText('tags saved for dunes_00001.png.');
expect(await savedTags(request, FIRST, 1)).toContain('kept_draft');
});
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
await expect(page.locator('.bulkbar')).toBeVisible();
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const refused = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '0');
const reread = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '1');
await infoButton(page, SECOND).click();
expect((await refused).status()).toBe(409);
expect((await reread).status()).toBe(200);
await expect(card(page, SECOND, 1)).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
await expect(panel(page).getByRole('button', { name: 'Refresh image collection', exact: true })).toHaveCount(0);
// A checked key of the old list could name a same-named file in another source: nothing stays checked.
await expect(page.locator('.bulkbar')).toBeHidden();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
});
test('a scope that cannot be mapped: Generate and Save are visibly disabled, the reason and Refresh sit beside them', async ({ page }) => {
await openSettled(page, images);
await expect(card(page, FIRST)).toBeVisible();
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
expect((await openTag(page)).status()).toBe(409);
expect((await rescoped).status()).toBe(409);
await expect(notice(page)).toContainText(SOURCE_CHANGED);
await expect(status(page)).toHaveText('');
const refresh = notice(page).getByRole('button', { name: 'Refresh image collection', exact: true });
await expect(refresh).toBeVisible();
for (const button of [generate(page), primary(page)]) {
await expect(button).toHaveAttribute('aria-disabled', 'true');
await expect(button).toHaveCSS('opacity', '0.55');
}
// The notice is its own first row of the sticky actions, above the buttons.
const rows = await dialog(page).locator('.dialog-actions').evaluate(actions => {
const noticeBox = actions.firstElementChild.getBoundingClientRect();
return { first: actions.firstElementChild.className, below: [...actions.querySelectorAll(':scope > button')].every(button => button.getBoundingClientRect().top >= noticeBox.bottom - 1) };
});
expect(rows).toEqual({ first: 'tag-action-notice', below: true });
const preview = dialog(page).locator('.tag-image-preview');
await expect(preview).toHaveText('Preview unavailable');
await expect(preview).toHaveAttribute('role', 'img');
const suggest = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggest.push(item.url()); });
// Pressed anyway (Playwright refuses an aria-disabled target, so the press is dispatched), nothing is sent.
await generate(page).dispatchEvent('click');
await primary(page).dispatchEvent('click');
expect(suggest).toEqual([]);
await page.screenshot({ path: test.info().outputPath('stale-source-tag-dialog.png') });
// Refresh closes the dialog and lists again with the current scopes; Tag then works.
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await refresh.click();
await expect(dialog(page)).toHaveCount(0);
await relisted;
await expect(card(page, FIRST)).toBeVisible();
expect((await openTag(page)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
await expect(generate(page)).not.toHaveAttribute('aria-disabled', 'true');
});
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
await infoButton(page, SECOND).click();
expect((await rescoped).status()).toBe(409);
await expect(panel(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await panel(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();
await relisted;
await expect(cards(page).first()).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
});
test('a Load more cursor the server cannot verify offers Refresh instead of a dead Retry', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 60 } })).toBeOK();
await openSettled(page, images);
await expect(cards(page).first()).toBeVisible();
await expect(page.locator('#gallery-continuation')).toContainText('more load as you scroll');
await expect(await request.post('/__test__/scope-key/rotate')).toBeOK();
const stale = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.has('cursor'));
await page.locator('#gallery-continuation').scrollIntoViewIfNeeded();
const answer = await stale;
expect(answer.status()).toBe(400);
expect((await answer.json()).code).toBe('cursor-stale');
const continuation = page.locator('#gallery-continuation');
await expect(continuation).toContainText('The image collection changed. Refresh image collection to continue.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && !new URL(item.url()).searchParams.has('cursor'));
await continuation.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
expect((await relisted).status()).toBe(200);
await expect(continuation).not.toContainText('The image collection changed');
});
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
await openSettled(page, images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
await card(page, FIRST).locator('.image-open').click();
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Opened again, it shows the file under its current source.
await card(page, FIRST, 1).locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open]')).toBeVisible();
await expect(page.locator('#viewer-dialog').getByText('Media could not be loaded. Retry this file.', { exact: true })).toBeHidden();
await expect.poll(() => page.locator('#viewer-dialog img').first().evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
});
test('a collection page finds a renumbered member again and stays on its members', async ({ page, request }) => {
// Members are stored by root path, not index (#331), so a renumbering keeps them; the page's scopes go stale.
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const first = listing.images.find(image => image.path === FIRST && image.root_index === 0);
const name = `Stale ${test.info().workerIndex}-${Date.now()}`;
const added = await request.post('/prompt_manager/collections/items', { data: { name, items: [
{ root_index: 0, root_scope: first.root_scope, path: FIRST }] } });
await expect(added).toBeOK();
const collections = (await (await request.get('/prompt_manager/collections')).json()).collections;
const { id } = collections.find(item => item.name === name);
await openSettled(page, `/prompt_manager/workspace#/collection/${id}`);
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('collection') === String(id));
await card(page, FIRST).locator('.image-open').click();
expect((await (await rescoped).json()).image.root_index).toBe(1);
expect((await relisted).status()).toBe(200);
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Tag image works on the member under its new source.
expect((await openTag(page, FIRST, 1)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
});
@@ -1,7 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
import { followRestore, SEEN_RESTORE_PILL, SEEN_RESTORE_TOAST } from './unseen-restore.mjs';
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
@@ -11,8 +10,8 @@ const STATUS_URL = '/prompt_manager/backup/status';
const PREVIEW_URL = '/prompt_manager/restore/preview';
const APPLY_URL = '/prompt_manager/restore/apply';
const WHEN = { dateStyle: 'medium', timeStyle: 'short' };
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
'prompt deletes change records. A restore always keeps one.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
test.use({ locale: 'en-US', timezoneId: 'UTC' });
@@ -266,24 +265,6 @@ test('a finished restore shows the open page again from the restored data', asyn
expect(await libraryTotal(request)).toBe(restoredTotal);
});
// #415: an open dialog waits out a finished restore, except a review: the plan it staged no longer describes storage,
// so it closes as dismissed (its stage discarded), as before.
test('a restore seen finishing elsewhere closes an open restore review and discards its stage', async ({ page, request }) => {
const bytes = await backupBytes(request);
const restoreRun = await followRestore(page);
await openBackups(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href', SEEN_RESTORE_PILL);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
const discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
expect((await discarded).status()).toBe(200);
await expect(dialog(page)).toHaveCount(0);
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
@@ -274,18 +274,11 @@ test('level filter is minimum-level over all six choices', async ({ page, reques
for (const line of (await lines(page).textContent()).split('\n')) expect(line).toMatch(/^\d\d:\d\d:\d\d [A-Z]+ +\S/);
expect(urls.at(-1)).toBe('?limit=100');
for (const minimum of LEVELS) {
// Wait for this level's answer: the previous level's lines also hold the CRITICAL line, so seeing it proved
// nothing, and under load the filtered answer painted after the check (#501 merge queue, firefox).
const answered = page.waitForResponse(item => new URL(item.url()).pathname === LOGS_URL
&& new URL(item.url()).search === `?limit=100&level=${minimum}`);
await level.selectOption(minimum);
await answered;
await expect.poll(() => urls.at(-1)).toBe(`?limit=100&level=${minimum}`);
await expect(lines(page)).toContainText('CRITICAL The database could not be opened');
await expect.poll(async () => {
const shown = await shownLevels(page);
return shown.every(name => LEVELS.indexOf(name) >= LEVELS.indexOf(minimum)) || `${minimum}: ${shown}`;
}).toBe(true);
const shown = await shownLevels(page);
expect(shown.every(name => LEVELS.indexOf(name) >= LEVELS.indexOf(minimum)), `${minimum}: ${shown}`).toBe(true);
if (minimum === 'DEBUG') await expect(lines(page)).toContainText('DEBUG Thumbnail cache lookup');
if (minimum === 'WARNING') {
await expect(lines(page)).not.toContainText('Database backup verified');
@@ -268,71 +268,6 @@ test('Watch for new outputs saves monitoring_enabled; a restart applies it', asy
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']);
});
test('D158: Save prompts from other workflows saves save_foreign_runs live; off while monitoring is off (#247)', async ({ page, request }, testInfo) => {
try {
const layout = await openSources(page);
const rows = layout.locator('section.surface > .setting-row');
await expect(rows.locator(':scope > div > label')).toHaveText(['Watch for new outputs', 'Save prompts from other workflows', 'Worker threads']);
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
const foreignRow = rows.filter({ has: page.locator('#foreign-runs') });
await expect(foreignRow.locator(':scope > div > p')).toHaveText('New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.');
await expect(foreign).toBeChecked();
await expect(foreign).toBeEnabled();
// Keyboard on desktop, a tap on the touch project.
if (testInfo.project.name === 'touch') await foreign.tap();
else {
await foreign.focus();
await page.keyboard.press('Space');
}
await expect(foreign).not.toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const response = await saved;
expect(response.request().postDataJSON()).toEqual(expect.objectContaining({ save_foreign_runs: false }));
expect((await response.json()).restart_required_fields).toEqual([]);
// Applied live: the monitor reads it for every file, so no restart is named.
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.save_foreign_runs).toBe(false);
// With Watch for new outputs drafted off nothing is watched: the row is disabled and its note says why.
const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true });
await monitoring.uncheck();
await expect(foreign).toBeDisabled();
const note = foreignRow.locator('p.small');
await expect(note).toHaveText('Applies while Watch for new outputs is on.');
await expect(foreign).toHaveAttribute('aria-describedby', await note.getAttribute('id'));
await monitoring.check();
await expect(foreign).toBeEnabled();
await expect(note).toHaveCount(0);
await expect(foreign).not.toHaveAttribute('aria-describedby', /./);
await expect(statusOf(page)).toHaveText('No unsaved changes.');
} finally {
// The server is shared by the spec's tests: put the default back whatever happened.
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
}
});
test('D158: saved off while the monitor still watches until a restart, the foreign row can still be set (D128)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK();
try {
const layout = await openSources(page);
await layout.getByRole('switch', { name: 'Watch for new outputs', exact: true }).uncheck();
await saveButton(page).click();
await expect(sourceList(page).locator('.operation p').first()).toHaveText('output · watching until restart');
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
await expect(foreign).toBeEnabled();
await expect(layout.locator('.setting-row').filter({ has: page.locator('#foreign-runs') }).locator('p.small'))
.toHaveText('Applies while Watch for new outputs is on.');
await foreign.uncheck();
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
expect((await saved).postDataJSON()).toEqual({ save_foreign_runs: false, expected_revision: expect.any(String) });
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
} finally {
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
}
});
test('worker range max is cpu_count', async ({ page, request }) => {
const { settings } = await serverJson(request, SETTINGS_URL);
const layout = await openSources(page);
+16 -113
View File
@@ -79,7 +79,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
]);
expect(writes).toEqual([]);
});
@@ -287,8 +287,7 @@ test('Appearance › Thumbnails saves the server thumbnail settings and never co
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK();
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK();
await page.goto(`${workspace}#/system/appearance`);
// #450 D156: the Viewer surface sits between Browsing & display and Thumbnails.
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(2);
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1);
await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails');
await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']);
const format = surface.locator('#thumbnail-format');
@@ -374,12 +373,13 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
['Rated images', number(statistics.rated_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
@@ -481,19 +481,17 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await openAppearance(page);
// #451: a fresh browser's Default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await page.locator('#default-view').selectOption('grid');
await expect(page.locator('#default-view')).toHaveValue('grid');
await page.locator('#default-view').selectOption('masonry');
await page.locator('#density').selectOption('compact');
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['grid', 'compact']);
// Back in Images (the page session's last visit): the toolbar's List is the current view's own and stays; the
// size the toolbar never changed follows the newer default (#451).
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
// The saved view opens as before.
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(imageCards(page).first()).toBeVisible();
@@ -503,109 +501,15 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await openImages(page);
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Grid', exact: true }))
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
.toHaveAttribute('aria-pressed', 'true');
} finally {
await request.delete(`/prompt_manager/saved-views/${view.id}`);
}
});
/** Choose a Default layout in System › Appearance and save it. */
async function saveDefaultLayout(page, value) {
await openAppearance(page);
await page.locator('#default-view').selectOption(value);
await saveAppearance(page, 'Saved in this browser.');
}
const imageLayoutOf = page => page.locator('#workspace-main .image-grid');
const layoutButton = (page, name) => page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name, exact: true });
test('a fresh browser opens Images in Masonry (#451)', async ({ page }) => {
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
// Nothing was stored for it: the default is the gallery settings' own.
expect(await page.evaluate(() => localStorage.getItem('gallerySettings'))).toBeNull();
// A stored explicit choice is kept.
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' })));
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
});
test('a Default layout saved later reaches the next Images visit in this tab (#451)', async ({ page }) => {
// The visit starts from Grid, then System saves Masonry without a reload.
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await page.locator('#system-nav a[data-route="system"]').click();
await expect(page.locator('#workspace-main .settings-layout')).toBeVisible();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(page.locator('#timeout')).toBeEnabled();
await page.locator('#default-view').selectOption('masonry');
await saveAppearance(page, 'Saved in this browser.');
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
});
test('a Default layout saved in another tab reaches this tab\'s next Images visit (#451)', async ({ page, context }) => {
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
const other = await context.newPage();
try {
await saveDefaultLayout(other, 'list');
} finally {
await other.close();
}
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
// A toolbar choice afterwards is this view's own again: a return keeps it.
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
});
// #451 D157: a cover crop keeps a portrait image's top; Masonry shows the whole image and keeps the centre.
test('cover crops keep the top of a portrait image; Masonry stays centred (#451)', async ({ page }) => {
const position = locator => locator.evaluate(node => getComputedStyle(node).objectPosition);
const portrait = page.locator('#workspace-main article.media-card .image-open img[width="1024"][height="1536"]').first();
await openImages(page);
await layoutButton(page, 'Grid').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await expect(portrait).toBeVisible();
expect(await position(portrait)).toBe('50% 20%');
await layoutButton(page, 'List').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
expect(await position(portrait)).toBe('50% 20%');
// The side panel's cover (also History's media inspector).
await portrait.locator('xpath=ancestor::article[1]').locator('.image-card-actions button').first().click();
await expect(panel(page).locator('.detail-cover')).toBeVisible();
expect(await position(panel(page).locator('.detail-cover'))).toBe('50% 20%');
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
expect(await position(portrait)).toBe('50% 50%');
// The Library's card art and a prompt history's shared gallery.
await page.goto(`${workspace}#/library`);
const art = page.locator('#workspace-main .prompt-card .card-art img').first();
await expect(art).toBeVisible();
expect(await position(art)).toBe('50% 20%');
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
await expect(imageCards(page).first()).toBeVisible();
expect(await position(imageCards(page).first().locator('.image-open img'))).toBe('50% 20%');
});
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
await openAppearance(page);
await page.locator('#default-view').selectOption('list');
@@ -883,13 +787,12 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
await expect(page.locator('#default-view')).toHaveValue('list');
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
await reset.click();
// #451 D157: the default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await expect(page.locator('#default-view')).toHaveValue('grid');
await expect(page.locator('#video-muted')).toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'masonry',
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
videoMute: true, showImageInfo: true });
});
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
test.beforeEach(async ({ page, request, browserName }) => {
@@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' &&
test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
await select(page, FIRST);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Delete…', 'Clear']);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
});
test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
await expect(bar(page).locator('strong')).toHaveText('2 selected');
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
// The finished job's files are read again: the selected card's panel shows its new image tags.
await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
expect(await savedTags(request, UNLINKED)).toEqual([]);
});
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
// The reviewed file's panel shows its saved tags without reselecting.
await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
});
+21 -66
View File
@@ -21,8 +21,6 @@ const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model'
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const status = page => dialog(page).locator('#tag-generation-status');
// D147: the reason a state blocks Save, on its own row in the sticky actions.
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const toast = page => page.locator('#workspace-toast');
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
@@ -458,32 +456,27 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
expect(await count()).toBe(mounted);
});
// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
// Tags filter buttons; the viewer's chips are plain.
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// D21/D39: the linked recipe's own tag, plain, and no image tags yet.
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
// D21/D39: the linked recipe's own tag, plain.
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
// The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
@@ -495,10 +488,9 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
@@ -508,53 +500,18 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const row = details.locator('.tags').last();
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
});
// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
// so they never read as part of it; the same in the side panel and the viewer details.
async function tagSectionPlacement(root) {
return root.evaluate(node => {
const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
return {
after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
};
});
}
const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
await page.goto(images);
const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(details)).toEqual(PLACED);
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
});
test('search finds an image by its image tag', async ({ page, request }) => {
@@ -611,7 +568,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await page.evaluate(() => {
const node = document.querySelector('aside[aria-label="Selected image details"]');
window.__loadingSeen = false;
@@ -630,13 +587,12 @@ test('the post-save re-read keeps the panel content until the new read lands', a
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
// While the re-read is held, the panel still shows the file's details.
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
const landed = inspected(page, FIRST);
release();
await landed;
await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
await page.unroute(matcher);
});
@@ -649,7 +605,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
await infoButton(page, SECOND).click();
await read;
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
// The newest match (SECOND) is selected once the filter applies.
@@ -772,8 +728,7 @@ test('D116: Retry leads the actions after an unconfirmed save and resends the sa
let lost = true;
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
await expect(notice(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(status(page)).toHaveText('');
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
@@ -809,7 +764,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
@@ -834,7 +789,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await openTag(page, SECOND, { goto: false });
@@ -1,142 +0,0 @@
// #450 (TESTING.md D156): every viewer has a full-screen toggle beside ✕. Full screen fills the browser window edge to
// edge, Centered is the concept's framed dialog; the button names the view it switches to. System › Appearance ›
// Viewer › "Open viewer in" chooses how the viewer opens; the button changes the current view only.
import { test, expect } from './fixtures.mjs';
import { seedConcept, openProduction } from './concept-inventory.mjs';
import { parityPng } from './local-inspect-files.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const CARDS = '.gallery-body > .image-grid > .media-card';
const viewer = page => page.locator('dialog#viewer-dialog[open]');
const modeButton = page => viewer(page).locator('.dialog-header > button#viewer-mode');
const details = page => viewer(page).locator('#viewer-details');
test.beforeEach(async ({ request }, testInfo) => { testInfo.manifest = await seedConcept(request); });
async function openImages(page, { width = 1600, height = 1100, theme = 'dark' } = {}) {
await page.setViewportSize({ width, height });
await openProduction(page, 'images', theme);
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
}
/** The dialog's box and edges against the window. */
const frame = page => viewer(page).evaluate(node => {
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
return { left: Math.round(box.left), top: Math.round(box.top), right: Math.round(innerWidth - box.right),
bottom: Math.round(document.documentElement.clientHeight - box.bottom), radius: style.borderTopLeftRadius,
border: style.borderTopWidth, fills: Math.abs(box.width - document.documentElement.clientWidth) <= 1 };
});
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
await openImages(page);
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
const centered = await frame(page);
expect(centered.left).toBeGreaterThan(0);
expect(centered.fills).toBe(false);
// Keyboard: the button is reachable and keeps focus after a switch.
await viewer(page).locator('#viewer-close').focus();
await page.keyboard.press('Shift+Tab');
await expect(modeButton(page)).toBeFocused();
await page.keyboard.press('Enter');
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(modeButton(page)).toBeFocused();
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
expect(await frame(page)).toEqual({ left: 0, top: 0, right: 0, bottom: 0, radius: '0px', border: '0px', fills: true });
// The details stay toggleable in full screen.
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(details(page)).toBeHidden();
await viewer(page).getByRole('button', { name: 'Show details', exact: true }).click();
await expect(details(page)).toBeVisible();
// Back to Centered: today's dialog, with no toggle while the collapse setting is off.
await modeButton(page).click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
expect(await frame(page)).toEqual(centered);
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
// Navigation keeps the current view; Esc closes from full screen; the next open is Centered again.
await modeButton(page).click();
await viewer(page).getByRole('button', { name: 'Next media', exact: true }).click();
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await viewer(page).getByRole('button', { name: 'Hide details', exact: true }).focus();
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
test('switching back to Centered keeps the collapse setting\'s toggle and hidden details (D97)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'images', 'dark');
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).toBeVisible();
await expect(details(page)).toBeHidden();
await modeButton(page).click();
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toBeVisible();
await modeButton(page).click();
await expect(details(page)).toBeHidden();
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toHaveAttribute('aria-expanded', 'false');
});
test('System › Appearance › Viewer chooses how the viewer opens; the button changes only the current view', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'system/appearance', 'dark');
const surface = page.locator('#workspace-main section.surface').filter({ has: page.locator('h2', { hasText: /^Viewer$/ }) });
await expect(surface.locator('.setting-row > div > :is(label, h3)')).toHaveText(['Open viewer in', 'Collapse viewer details by default']);
const select = surface.getByRole('combobox', { name: 'Open viewer in', exact: true });
await expect(select).toHaveValue('centered');
await select.selectOption('fullscreen');
await page.getByRole('button', { name: 'Save preferences', exact: true }).click();
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || '{}').viewerOpenMode)).toBe('fullscreen');
await page.evaluate(() => { location.hash = '#/images'; });
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
await modeButton(page).click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings')).viewerOpenMode)).toBe('fullscreen');
});
test('Inspect opens full screen with its details when that is the default (D155)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ viewerOpenMode: 'fullscreen', sidebarCollapsedByDefault: true })));
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'inspect', 'dark');
await page.locator('#local-file').setInputFiles(parityPng('f02.png'));
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(details(page)).toBeVisible();
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toBeVisible();
});
test('the Library gallery viewer has the button too', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, `library/${testInfo.manifest.prompts[0].positive_id}`, 'dark');
await page.locator('.media-card .image-open').first().click();
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
});
test('at 390px the viewer is already full-bleed and the button hides', async ({ page }) => {
await openImages(page, { width: 390, height: 844 });
await expect(viewer(page).locator('#viewer-mode')).toBeHidden();
await expect(viewer(page).locator('#viewer-close')).toBeVisible();
});
test('full screen holds at 200% zoom and coarse pointers get a 40px target', async ({ page, browserName }) => {
await openImages(page);
await modeButton(page).click();
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
const zoomed = await frame(page);
expect(zoomed).toMatchObject({ left: 0, top: 0, right: 0, bottom: 0 });
await page.evaluate(() => { document.documentElement.style.zoom = '1'; });
if (await page.evaluate(() => matchMedia('(pointer: coarse)').matches)) {
const size = await modeButton(page).evaluate(node => [node.getBoundingClientRect().width, node.getBoundingClientRect().height]);
expect(Math.min(...size)).toBeGreaterThanOrEqual(40);
}
test.info().annotations.push({ type: 'engine', description: browserName });
});
-3
View File
@@ -4,7 +4,6 @@ import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
@@ -416,8 +415,6 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
// The Grid's footer reservations (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(24);
@@ -1,167 +0,0 @@
// #444 (D149): Tags & categories tag search, tag sort and the Untagged prompts row, against the real server.
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/tags';
const main=page=>page.locator('#workspace-main');
const tagSurface=page=>main(page).locator('.two-col > section.surface').first();
const table=page=>main(page).getByRole('table',{name:'Reusable tags',exact:true});
const rows=page=>table(page).locator('tbody tr');
const names=page=>rows(page).locator('td:first-child');
const tagRow=(page,name)=>rows(page).filter({has:page.locator('td:first-child').getByText(name,{exact:true})});
const search=page=>page.getByRole('searchbox',{name:'Search tags',exact:true});
const sort=page=>page.getByRole('combobox',{name:'Sort tags',exact:true});
const untagged=page=>tagSurface(page).locator('.operation').filter({has:page.getByRole('heading',{level:3,name:'Untagged prompts',exact:true})});
const status=page=>tagSurface(page).locator('search.vocabulary-tools [role="status"]');
const shelf=page=>page.locator('article[data-prompt-id]');
const filtersButton=(page,name)=>page.locator('#workspace-main search.toolbar').getByRole('button',{name,exact:true});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
async function seed(request,data={}) {
const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();
}
let manifest;
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await armUnseenRestores(page);
// Two minimal recipes without tags (the concept recipes all carry tags).
manifest=await seed(request,{extra_categories:2});
page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);});
async function open(page) {
await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length);
}
test('the tag vocabulary surface orders its paragraph, Untagged prompts, the search and sort, then the table',async({page})=>{
await open(page);
const order=await tagSurface(page).evaluate(node=>[...node.children].map(child=>child.matches('.operation')?'operation':
child.matches('search.toolbar')?'toolbar':child.matches('.table-wrap')?'table':child.tagName.toLowerCase()).slice(0,5));
expect(order).toEqual(['h2','p','operation','toolbar','table']);
await expect(untagged(page).locator('p')).toHaveText('2 positive prompts');
await expect(untagged(page).getByRole('button')).toHaveText(['Browse']);
await expect(search(page)).toHaveAttribute('placeholder','Search tags…');
await expect(search(page)).toHaveAttribute('maxlength','200');
await expect(sort(page).locator('option')).toHaveText(['Oldest first','Name A–Z','Name Z–A','Most prompts','Fewest prompts']);
await expect(sort(page)).toHaveValue('created');
await expect(tagSurface(page).locator('label.search-field svg, label.search-field .ca-icon').first()).toBeVisible();
});
test('search narrows the tags on the server, says when nothing matches and keeps the categories',async({page})=>{
await open(page);
const categories=await main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3').allTextContents();
const reads=[];page.on('request',request=>{if(request.url().includes('/vocabulary/tags?'))reads.push(new URL(request.url()).searchParams);});
await search(page).fill('LIGHT');
await expect(names(page)).toHaveText(['warm_light','soft_light']);
await expect(status(page)).toHaveText('2 tags');
expect(reads.at(-1).get('search')).toBe('LIGHT');
// Literal: an underscore is not a wildcard.
await search(page).fill('a_c');await expect(rows(page)).toHaveCount(0);
await expect(tagSurface(page).locator('p.empty')).toHaveText('No tags match this search.');
await expect(status(page)).toHaveText('No tags match this search.');
await expect(main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3')).toHaveText(categories);
await search(page).fill('');await expect(rows(page)).toHaveCount(manifest.tags.length);
await expect(status(page)).toHaveText(`${manifest.tags.length} tags`);
});
test('sort orders every tag by name or by prompts, ties in creation order',async({page})=>{
await open(page);
const created=await names(page).allTextContents();
await sort(page).selectOption('name');
const byName=[...created].sort((a,b)=>a.toLowerCase()<b.toLowerCase()?-1:a.toLowerCase()>b.toLowerCase()?1:0);
await expect(names(page)).toHaveText(byName);
await sort(page).selectOption('name-desc');await expect(names(page)).toHaveText([...byName].reverse());
await sort(page).selectOption('prompts');
await expect(rows(page).first().locator('td:first-child')).toHaveText('cinematic');
const counts=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number);
expect(counts).toEqual([...counts].sort((a,b)=>b-a));
await sort(page).selectOption('prompts-asc');
const ascending=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number);
expect(ascending).toEqual([...ascending].sort((a,b)=>a-b));
await sort(page).selectOption('created');await expect(names(page)).toHaveText(created);
});
test('a rename while searching re-reads the same search and sort',async({page})=>{
await open(page);
await sort(page).selectOption('name');await search(page).fill('light');
await expect(names(page)).toHaveText(['soft_light','warm_light']);
await tagRow(page,'warm_light').getByRole('button',{name:'Rename / merge',exact:true}).click();
const box=page.locator('dialog.vocabulary-dialog');
await box.getByLabel('New name; existing names merge',{exact:true}).fill('amber_light');
await box.getByRole('button',{name:'Apply',exact:true}).click();await expect(box).toHaveCount(0);
await expect(names(page)).toHaveText(['amber_light','soft_light']);
await expect(tagRow(page,'amber_light').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
await expect(search(page)).toHaveValue('light');await expect(sort(page)).toHaveValue('name');
});
test('Untagged prompts Browse opens the Library on exactly the prompts without tags',async({page})=>{
await open(page);
await untagged(page).getByRole('button',{name:'Browse',exact:true}).click();
await expect(page).toHaveURL(/#\/library$/);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
await expect(shelf(page)).toHaveCount(2);
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(/^2 positive prompts · /);
for(const card of await shelf(page).all())await expect(card.locator('.tags .chip')).toHaveCount(0);
await filtersButton(page,'Filters · 1').click();
await expect(refine(page).getByRole('combobox',{name:'Tag match',exact:true})).toHaveValue('untagged');
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeDisabled();
// Changing only the Tag match applies it: the whole shelf returns.
await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('all');
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeEnabled();
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();
await expect(filtersButton(page,'Filters')).toBeVisible();
await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2);
});
test('Refine › Prompts without tags on the shelf drops typed tags and Clear restores All',async({page})=>{
await page.goto('/prompt_manager/workspace#/library');await expect(shelf(page).first()).toBeVisible();
await filtersButton(page,'Filters').click();
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill('cinematic');
await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('untagged');
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();
await expect(shelf(page)).toHaveCount(2);
await filtersButton(page,'Filters · 1').click();
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();
await expect(filtersButton(page,'Filters')).toBeVisible();await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2);
});
test('an older server that ignores the search shows an error with Retry, never an unfiltered list',async({page})=>{
await open(page);
// An un-restarted server answers without the echo.
await page.route('**/prompt_manager/vocabulary/tags?*',async route=>{
const response=await route.fetch();const body=await response.json();delete body.search;delete body.sort;
await route.fulfill({response,json:body});
});
await search(page).fill('light');
await expect(tagSurface(page).locator('.vocabulary-error')).toBeVisible();
await expect(rows(page)).toHaveCount(0);
await expect(tagSurface(page).getByRole('button',{name:'Retry',exact:true})).toBeVisible();
});
test('the search and sort fit 390px without a page scroll',async({page})=>{
await page.setViewportSize({width:390,height:844});await open(page);
const layout=await tagSurface(page).evaluate(node=>{
const tools=node.querySelector('search.vocabulary-tools'),field=tools.querySelector('.search-field'),select=tools.querySelector('select');
const box=node.getBoundingClientRect(),fieldBox=field.getBoundingClientRect(),selectBox=select.getBoundingClientRect();
return {inside:fieldBox.left>=box.left&&selectBox.right<=box.right+0.5,fullField:Math.round(fieldBox.width)===Math.round(tools.getBoundingClientRect().width)};
});
expect(layout).toEqual({inside:true,fullField:true});
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
});
// Artifact-only captures for the maintainer's quick check (no pixel assertions): a search with a sort, the Library on
// Untagged prompts and its Refine dialog.
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`D149 captures ${width} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});await open(page);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await sort(page).selectOption('prompts');await search(page).fill('i');await expect(status(page)).toHaveText(/^\d+ tags$/);
await page.mouse.move(0,0);
await captureCanonicalState(page,testInfo,`production-tags-search-${width}-${theme}`,'#workspace-main');
await untagged(page).getByRole('button',{name:'Browse',exact:true}).click();await expect(shelf(page)).toHaveCount(2);
await captureCanonicalState(page,testInfo,`production-library-untagged-${width}-${theme}`,'#workspace-main');
await filtersButton(page,'Filters · 1').click();await expect(refine(page)).toBeVisible();
await captureCanonicalState(page,testInfo,`production-refine-untagged-${width}-${theme}`,'dialog[open]');
});
+5 -51
View File
@@ -1,9 +1,9 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/tags';
// Prompts per concept tag (app.js:71-165): first appearance order, Library Browse counts.
@@ -552,31 +552,6 @@ test('conflict and restore states keep the dialog and write nothing',async({page
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
// #415: a restore the page sees finish while Create tag is open keeps the dialog and its entries; the page is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps an open dialog and reloads the page after it closes',async({page,request})=>{
const saved=await backup(request);
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
const restoreRun=await followRestore(page);const writes=trackWrites(page);await openPage(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await expect(tagRow(page,'fog')).toHaveCount(1);
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
await field(box,'Tag name').fill('sunrise');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer:saved}}})).toBeOK();
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(box).toBeVisible();await expect(field(box,'Tag name')).toHaveValue('sunrise');
await expect(tagRow(page,'fog')).toHaveCount(1);
await button(box,'Apply').click();
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
expect(tagNames(await snapshot(request))).not.toContain('sunrise');
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
test('lost response is outcome-unknown',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
@@ -786,15 +761,6 @@ async function openProductionTags(page,theme,motion) {
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
}
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
/** D149 donor: the concept's own page toolbar (Prompt library), whose search field and select the tag search reuses. */
async function conceptToolbar(page,theme) {
await page.goto('about:blank');await page.goto(`${concept}#library`);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
const inventory=await page.locator('#main search.toolbar').evaluate(toolbarInventoryOf);
await page.goto('about:blank');await page.goto(`${concept}#tags`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
return inventory;
}
const tagToolbar=page=>page.locator('#workspace-main .two-col > section.surface:first-child > search.toolbar').evaluate(toolbarInventoryOf);
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
async function chromeOf(page,production) {
const scope=page.locator(production?'#workspace-main':'#main');
@@ -814,7 +780,7 @@ async function chromeOf(page,production) {
}
/** Concept rows are the concept's own sample tags; production appends the fixture-only tags after them (D4 creation order). */
function comparePage(reference,actual) {
const expected=mapVocabularyConcept(reference,{},d149Of(actual));
const expected=mapVocabularyConcept(reference);
const shared=expected.table.rows.length;
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
@@ -827,18 +793,13 @@ for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`tags
await openConceptTags(page,theme,motion);const reference=await pageOf(page,false);
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-tags-${width}-${theme}`,'#main');
const referenceChrome=await chromeOf(page,false);
const referenceTools=await conceptToolbar(page,theme);
await openProductionTags(page,theme,motion);const actual=await pageOf(page,true);
const actualTools=await tagToolbar(page);
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-tags-${width}-${theme}`,'#workspace-main');
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true),
toolbarDiff:diffInventory(referenceTools,actualTools)};
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true)};
}
const path=testInfo.outputPath('tags-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('tags-geometry',{path,contentType:'application/json'});
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome,toolbarDiff}] of Object.entries(result)) {
// D149 (#444): the tag search and sort carry the concept page toolbar's tokens.
expect.soft(toolbarDiff,`${motion} tag search and sort against the concept toolbar`).toEqual([]);
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome}] of Object.entries(result)) {
expect.soft(referenceChrome,`${motion} concept chrome`).toMatchObject({breadcrumb:'Tags',title:'Tags',ring:{visible:true},linkRing:{outlineOffset:'3px'}});
expect.soft(actualChrome,`${motion} breadcrumb, title and focus ring`).toEqual(referenceChrome);
expect.soft(reference.heading.actions.buttons.map(item=>item.text),`${motion} concept heading actions`).toEqual(['+ Create tag','Auto-tag images']);
@@ -908,13 +869,6 @@ test('mutation check: the comparison reports a changed surface padding and a cha
expect(paths).toContain('surfaces.1.paddingLeft');
expect(paths).toContain('table.rows.0.actions.0');
expect(paths).toContain('table.rowActions.buttons.0.text');
// D149: a moved Untagged prompts row and a restyled tag search are reported too.
await page.addStyleTag({content:'.vocabulary-main .surface > .operation{margin-top:6px!important}.vocabulary-tools .search-field{border-radius:9px!important}'});
const moved=comparePage(reference,await pageOf(page,true)).diff.map(item=>item.path);
expect(moved).toContain('d149.offset');
const tools=await conceptToolbar(page,'dark');await openProductionTags(page,'dark','no-preference');
await page.addStyleTag({content:'.vocabulary-tools .search-field{border-radius:9px!important}'});
expect(diffInventory(tools,await tagToolbar(page)).map(item=>item.path)).toContain('field.borderTopLeftRadius');
// The dialog comparison is not vacuous either: a removed Cancel and a recolored field are reported.
const donors=await conceptDonors(page,'dark');
await openConceptTags(page,'dark','no-preference');await conceptDialogs.create(page);
+1 -1
View File
@@ -472,7 +472,7 @@ test('System is native: the concept shell, section navigation, sections and the
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'section.surface', 'div.save-row'] /* D156: Browsing & display, Viewer, Thumbnails */,
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
sources: ['div.notice', 'section.surface', 'div.save-row'],
integrations: ['section.surface', 'section.surface', 'div.save-row'],
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
+2 -10
View File
@@ -194,16 +194,9 @@ def tagging_snapshot(api: PromptManagerAPI) -> dict:
def register_tagging_commands(
routes: web.RouteTableDef,
api: PromptManagerAPI,
root: Path,
model,
on_reset=lambda: None,
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
) -> None:
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input.
``on_reset`` runs first on every tagging reset (the source-change undo).
"""
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
# The fake download (``/__test__/tagging/download``): waits on ``event``,
# then answers ``result``. Every reset sets the event and puts back the
# model's raising default, so no test can leave a worker hanging.
@@ -237,7 +230,6 @@ def register_tagging_commands(
raise web.HTTPBadRequest()
if not 0 <= extra <= MAX_EXTRA_IMAGES:
raise web.HTTPBadRequest()
on_reset()
model.unavailable = set()
model.missing_packages = {}
model.suggestions = None
+9 -28
View File
@@ -263,18 +263,15 @@ test.describe('public chronological history with the actual acknowledged host br
});
});
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
@@ -309,21 +306,6 @@ test('fresh registered PM variants preserve exact raw whitespace and known empty
expect(value.negative.raw).toBe('');
expect(value.negative.effective).toBe('');
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'recorded', source: 'runtime_return' });
// #438: the Library stores the stripped text; the run is in that prompt's history
expect(value.positive_prompt_id, 'the run names the prompt the Library saved').toEqual(expect.any(Number));
const runId = (await readExecutionRuns(execution.databasePath, id)).rows[0].run_id;
const response = await execution.request.get(`/prompt_manager/library/${value.positive_prompt_id}/history`);expect(response.ok()).toBe(true);
expect((await response.json()).items.map(item => item.run_id)).toContain(runId);
if (kind !== 'PromptManager') continue;
// #460: an upper-case re-queue counts toward the same prompt and joins its history
const upper = await submit(execution.request, recipeGraph({ kind, text: `${kind.toUpperCase()} E\u0301` }));
await history(execution.request, upper);
const variant = (await capture(execution, upper)).contexts[0];
expect(variant.positive.raw).toBe(`${kind.toUpperCase()} E\u0301`);
expect(variant.positive_prompt_id, 'the case variant names the same prompt').toBe(value.positive_prompt_id);
const variantRun = (await readExecutionRuns(execution.databasePath, upper)).rows[0].run_id;
const scoped = await execution.request.get(`/prompt_manager/library/${value.positive_prompt_id}/history`);
expect((await scoped.json()).items.map(item => item.run_id)).toContain(variantRun);
}
});
@@ -387,7 +369,7 @@ test('successful source capture survives downstream failure and real interruptio
}
});
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
const first = await submit(execution.request, graph);
await history(execution.request, first);
@@ -395,9 +377,8 @@ test('cached dynamic raw comes from the reused execution while literal affixes a
const second = await submit(execution.request, graph);
await history(execution.request, second);
const value = (await capture(execution, second)).contexts[0];
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw.provenance).toBe('unknown');
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
});
+2 -20
View File
@@ -287,14 +287,6 @@ async function capturedContext(execution, id) {
return result.captures[result.rows[0].run_id].contexts[0];
}
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
async function expectTrackedRuns(execution, promptId, expected) {
await expect.poll(async () => {
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
return response.ok() ? (await response.json()).recorded_run_count : null;
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
}
async function firstRun(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const built = await build(page, { ...testCase, prefix });
@@ -317,8 +309,6 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
const counts = await runCounts(execution, first.context);
expect(counts.positive).toBe(3);
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
// #403: the cached jobs are tracked runs of the prompt too
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
});
}
@@ -339,7 +329,6 @@ test.describe('cached runs in one renderer (#347)', () => {
else expect(again.cached).toContain(first.promptNode);
}
expect((await runCounts(execution, first.context)).positive).toBe(3);
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
}
@@ -363,9 +352,6 @@ test.describe('cached runs in one renderer (#347)', () => {
const bContext = await capturedContext(execution, second.id);
expect(bContext.positive.raw).toBe(b);
expect((await runCounts(execution, bContext)).positive).toBe(1);
// #403: the third job reuses A's execution, not the newest (B's)
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
@@ -404,14 +390,10 @@ test.describe('cached runs in one renderer (#347)', () => {
} });
expect(rewired.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(1);
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
expect(cachedContext.positive_prompt_id).toBeNull();
expect((await runCounts(execution, first.context)).positive).toBe(2);
// The rewired job used the prompt as its negative: neither number counts it
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
});
});
+2 -9
View File
@@ -334,15 +334,8 @@ async def image_monitor(request: web.Request) -> web.Response:
@PromptServer.instance.routes.get("/pm_execution_fixture/monitor")
async def image_monitor_state(request: web.Request) -> web.Response:
"""File names of images waiting for their run's recorded outputs: keyless
ones (``awaiting``) and foreign ones (``foreign``, #247)."""
"""File names of keyless images waiting for their run's recorded outputs."""
monitor = _plugin_module("utils.image_monitor")._monitor_instance
handler = getattr(monitor, "handler", None)
waiting = handler.awaiting_history() if handler is not None else []
foreign = handler.awaiting_foreign() if handler is not None else []
return web.json_response(
{
"awaiting": [os.path.basename(p) for p in waiting],
"foreign": [os.path.basename(p) for p in foreign],
}
)
return web.json_response({"awaiting": [os.path.basename(p) for p in waiting]})

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