Compare commits

..
Author SHA1 Message Date
Vito Sansevero f87f2c43d0 Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete
# Conflicts:
#	database/models.py
#	py/api/jobs.py
2026-10-29 13:38:17 -07:00
Vito Sansevero 3e2339fe96 Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete 2026-10-29 11:10:46 -07:00
Vito Sansevero 52531a8be4 Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete
# Conflicts:
#	database/library.py
#	web/js/workspace/library-state.mjs
#	web/js/workspace/library.mjs
2026-10-29 07:41:30 -07:00
Vito Sansevero 7ff785cb5a Merge branch 'feat/444-library-folder-or' of github.com-vitosans:ComfyAssets/ComfyUI_PromptManager into feat/443-bulk-prompt-delete
# Conflicts:
#	database/library.py
#	web/js/workspace/library.mjs
2026-10-29 06:03:39 -07:00
Vito Sansevero 775e85b73a Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete
main's only new commit is #452's squash, whose final head (e14ec03) this
branch already holds through #456. Every conflict keeps this branch's side,
so the tree equals e9d01c9's.
2026-10-29 05:45:42 -07:00
Vito Sansevero c1b5fd3fc0 Merge remote-tracking branch 'origin/main' into feat/444-library-folder-or
# Conflicts:
#	TESTING.md
#	database/library.py
#	database/saved_views.py
#	tests/browser/workspace-library.spec.mjs
#	tests/browser/workspace-saved-views.spec.mjs
#	tests/js/workspace-library-state.test.js
#	tests/js/workspace-saved-views.test.js
#	tests/test_saved_views.py
#	web/js/workspace/library-state.mjs
#	web/js/workspace/library.mjs
#	web/js/workspace/saved-views.mjs
#	web/js/workspace/workspace-dialogs.mjs
2026-10-29 05:26:43 -07:00
Vito Sansevero e9d01c917b Merge commit '917aa838cbe1d2f6f2c335885096812a28626cce' into feat/443-bulk-prompt-delete 2026-10-29 05:25:16 -07:00
Vito Sansevero 0d59de637e fix(library): say why an archived prompt is archived, and name variants only when it has any (#443)
The archived prompt page's notice always said "This prompt and its
negative variants are hidden", also for a saved-pair prompt with none.
It now says the prompt is archived because it is in a saved pair, and
counts its negative variants only when there are any. From the live
test of #470.
2026-10-29 04:37:21 -07:00
Vito Sansevero 917aa838cb fix(library): nest Refine folders by path segment; backslash separates only on Windows (#444)
Live test B1: the Folder list indented a server list sorted as full-path
text, so "-" and "_" sorted before "/" and children landed under the wrong
parent (kiko/char under kiko-v6), with case variants interleaved. The
options are now a tree built by segment: each folder directly under its own
parent, case variants distinct siblings.

On POSIX a folder named "Metadata\Klein..." was listed but filtered to no
prompts, because normalize_folder read "\" as a separator. It now does so
only on Windows (ntpath), as the listing already did.

Refs #444
2026-10-29 04:36:37 -07:00
Vito Sansevero 8e9750226d fix(library): name the saved pair, not negative variants, as the archive reason (#443)
A prompt in a saved pair with no negative variants was described in the
Delete… review as "1 prompt with negative variants: hidden, restorable".
Paired prompts are archived because of the pair, so the Archive row now
reads "N prompts in a saved pair: hidden, restorable" and the first
sentence explains the pair. Found in the live test of #470.
2026-10-29 04:35:28 -07:00
Vito Sansevero 78993dcb4d Merge #456 (feat/444-library-folder-or) into feat/443-bulk-prompt-delete
Keeps both features: #444's folder filter, tag match (all/any/untagged),
tag search and sort with #443's archive/delete and Show archived shelf.
The ranked tag sort and the Untagged prompts count now hide archived
parents, as the other tag counts and the shelf already do.
2026-10-29 00:43:07 -07:00
Vito Sansevero c36ea7a0fb test(saved-views): give the D153 Library view the folder key (#444)
Refs #444
2026-10-29 00:06:10 -07:00
Vito Sansevero 1153de26fc Merge branch 'feat/444-tags-page-search' into feat/444-library-folder-or 2026-10-29 00:06:05 -07:00
Vito Sansevero e14ec03945 test(saved-views): give the D153 Library view the tag_mode key (#444)
The Library saved-view state now holds tag_mode, and the API requires
every key, so main's new "Highest rated" spec posts it too.

Refs #444
2026-10-29 00:05:59 -07:00
Vito Sansevero 990ebee79b Merge branch 'feat/444-tags-page-search' into feat/444-library-folder-or
# Conflicts:
#	TESTING.md
#	tests/js/workspace-saved-views.test.js
#	web/js/workspace/library-state.mjs
#	web/js/workspace/library.mjs
#	web/js/workspace/saved-views.mjs
2026-10-29 00:01:02 -07:00
Vito Sansevero d44cd198c4 Merge remote-tracking branch 'origin/main' into feat/444-tags-page-search
# Conflicts:
#	TESTING.md
#	tests/js/workspace-saved-views.test.js
#	web/js/workspace/library-state.mjs
#	web/js/workspace/library.mjs
#	web/js/workspace/saved-views.mjs
2026-10-29 00:00:16 -07:00
Vito Sansevero 7ae30212b9 Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete
# Conflicts:
#	web/js/workspace/library.mjs
2026-10-28 22:59:22 -07:00
Vito Sansevero 94355a6311 Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete
# Conflicts:
#	TESTING.md
#	tests/browser/library-concept.spec.mjs
#	web/js/workspace/library-state.mjs
#	web/js/workspace/library.mjs
2026-10-28 22:12:35 -07:00
Vito Sansevero 4a7875c71d Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete 2026-10-28 20:45:24 -07:00
Vito Sansevero d9988f246a fix(library): lay out a loaded page of prompts without waiting for an animation frame (#443)
A starved renderer (headless WebKit under CPU load, a background tab) can
hold requestAnimationFrame for seconds. The shelf laid out each loaded page
only in its next frame, so the grid stayed empty after the first load or a
delete's reload while the caption already showed the new count. A loaded
batch is now laid out at once; measurements still flush per frame.

The regression test holds every animation frame and fails before the fix.
2026-10-28 20:45:24 -07:00
Vito Sansevero 4186c9a053 Merge remote-tracking branch 'origin/main' into feat/443-bulk-prompt-delete
# Conflicts:
#	TESTING.md
#	tests/browser/workspace-library-selection.spec.mjs
#	tools/vendor/frontend-coverage-baseline.json
#	web/js/workspace/library-selection.mjs
#	web/js/workspace/library.mjs
2026-10-28 20:02:04 -07:00
Vito Sansevero 07c16dca58 fix(library): let saved-view defaults fill the missing #444 filters
libraryViewState passed `?? 'all'` and `?? ''` fallbacks that
normalizeViewState already applies to an undefined value. The redundant
branches were never taken in the page, which dropped saved-views.mjs below
its 95% branch floor. A unit now covers a shelf without the new filters.

Refs #444
2026-10-28 20:00:49 -07:00
Vito Sansevero cd82cdcd1b test(library): cover the Delete… review failure and close-only paths; record coverage floors (#443) 2026-10-28 19:47:32 -07:00
Vito Sansevero a6927489a8 Merge remote-tracking branch 'origin/main' into feat/444-library-folder-or
# Conflicts:
#	TESTING.md
2026-10-28 19:17:22 -07:00
Vito Sansevero 323f2617d8 feat(library): Delete… on the selection bar, Show archived prompts and Restore (#443)
The Library bar's Delete… previews the selection and opens the shared
review: what is deleted (in no saved pair), archived (paired: hidden,
restorable) or kept (tagged __protected__), the safety backup and what
cannot be undone. Show archived prompts (N) lists archived prompts with
Restore on their bar; an archived prompt's page shows an Archived notice
with Restore. TESTING.md records D152; the route snapshot gains the three
workspace routes.
2026-10-28 18:51:06 -07:00
Vito Sansevero 53cfd8b035 Merge branch 'feat/444-tags-page-search' into feat/444-library-folder-or
# Conflicts:
#	TESTING.md
#	web/js/workspace/library.mjs
2026-10-28 18:41:52 -07:00
Vito Sansevero f30db7713d feat(library): archive paired and delete unpaired selected prompts on the server (#443)
A prompt_archive table marks Library parents hidden from the shelf; older
builds ignore it. New routes /prompt_manager/library/delete/preview,
/library/delete and /library/restore classify a selection (delete,
archive, protected, already archived, not found), execute exactly what was
reviewed under a records hold, take a verified safety backup (disk checked)
before any delete, and restore archived prompts. The shelf, search and the
shelf-facing counts honor the archive (?shelf=active|archived); archiving
repairs a stale prompt hash so scans find the archived row.
2026-10-28 18:26:27 -07:00
Vito Sansevero 567b06ac48 Merge remote-tracking branch 'origin/main' into feat/444-tags-page-search
# Conflicts:
#	TESTING.md
#	tests/js/workspace-library-state.test.js
#	web/js/workspace/library-state.mjs
#	web/js/workspace/library.mjs
2026-10-28 18:06:52 -07:00
Vito Sansevero a543ecff6f Merge remote-tracking branch 'origin/main' into feat/442-bulk-prompt-tags
# Conflicts:
#	TESTING.md
#	database/library_commands.py
#	py/api/prompts.py
#	tests/browser/library-concept.spec.mjs
#	tests/browser/workspace-library-selection.spec.mjs
#	tests/js/workspace-library-selection.test.js
#	web/js/workspace/library-selection.mjs
#	web/js/workspace/library.mjs
2026-10-28 17:53:48 -07:00
Vito Sansevero 4888427a1e Merge remote-tracking branch 'origin/main' into feat/444-tags-page-search 2026-10-28 17:31:47 -07:00
Vito Sansevero 9f55f0cc79 Merge feat/444-tags-page-search into feat/444-library-folder-or
Stack PR 2 on PR 1 so one restart covers both. The shared Library
tag_mode now takes all, any and untagged on the route, the filter, saved
views and Refine's Tag match; untagged still refuses tags and a folder
combines with every mode.

Refs #444
2026-10-28 17:31:11 -07:00
Vito Sansevero 4022298446 docs(library): record Add tags and select-all as D151 (#442) 2026-10-28 17:16:54 -07:00
Vito Sansevero 6372bc7c63 feat(library): filter prompts by output folder and browse tags with Any (#444)
Refine this view on the Prompt library gains the concept's Folder (folders
below an output root, read when the dialog opens) and a Tag match select
(All / Any). The listing takes folder and tag_mode, matches folders on the
normalized file_path under every spelling of each output root, echoes both
so a stale server is an error, and saved Library views keep them. Recorded
as D150.

Refs #444
2026-10-28 17:15:25 -07:00
Vito Sansevero d4d99b7e18 feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)
Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444
2026-10-28 17:07:50 -07:00
Vito Sansevero ebd43a8140 feat(library): add tags to many selected prompts and select every loaded prompt (#442)
The Library selection bar (D148) gets Add tags and select-all of the loaded
shelf, the legacy admin page's bulk Add Tags and select-all checkbox.

Add tags posts {prompt_ids, tags, expected_epoch} to
POST /prompt_manager/bulk/tags: in one BEGIN IMMEDIATE transaction the server
checks the storage epoch, normalizes the names as image tags are, gives each
Library parent only the names it lacks (casefold), finds or inserts tag rows
by exact name only when some prompt gets them, and skips prompts that would
pass 20 tags. Prompt tags only. Both bulk/tags forms refuse a non-object body
with 400.

Ctrl/Cmd+A in the grid and "Select all N loaded" in the bar select every
loaded prompt; the offer follows each loaded batch.

Closes #442
2026-10-28 17:04:38 -07:00
Vito Sansevero 72df65113a feat(library): set the category on many selected prompts (#437)
The Prompt library shelf gets the concept's Images selection checkbox on each
card and its .bulkbar (N selected, Set category, Clear), recorded as D148.
Set category opens the shared one-value dialog and posts
{prompt_ids, category, expected_epoch} to POST /prompt_manager/bulk/category:
the server checks the storage epoch the shelf was read under, then sets the
category on the Library parents in one BEGIN IMMEDIATE transaction, writing
only rows that change. A restored storage answers 409 storage-changed and
writes nothing. The legacy admin and images_filter bodies are unchanged.

This replaces the legacy admin page's bulk Set Category, a cutover
prerequisite (#334).

Closes #437
2026-10-28 16:17:23 -07:00
177 changed files with 1136 additions and 11858 deletions
+5 -73
View File
@@ -21,22 +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.
# native-trash-linux (#543) has no green main runs yet: it installs the dev requirements and runs
# one test module, about 1 minute by the same estimate, so ceil(1.5 x 2) + 3 gives it 6 minutes.
# 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:
@@ -82,64 +75,3 @@ jobs:
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
# The same tests as the fake mount table, on a real ext4 mount used as the output root (#543). A
# runner can mount with sudo; the tests run as the normal user, so owner and mode checks are real.
native-trash-linux:
name: native trash on a real mount
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Mount an ext4 image
run: |
truncate -s 64M "$RUNNER_TEMP/drive.img"
mkfs.ext4 -q "$RUNNER_TEMP/drive.img"
mkdir "$RUNNER_TEMP/drive"
sudo mount -o loop "$RUNNER_TEMP/drive.img" "$RUNNER_TEMP/drive"
sudo rmdir "$RUNNER_TEMP/drive/lost+found"
sudo chown "$(id -u):$(id -g)" "$RUNNER_TEMP/drive"
findmnt "$RUNNER_TEMP/drive"
- name: Native trash proof
timeout-minutes: 4
run: PM_TRASH_MOUNT="$RUNNER_TEMP/drive" python -m unittest tests.test_trash_mount_root -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,
+6 -7
View File
@@ -251,9 +251,9 @@ match Classic and Focused.
- **PromptManagerText**: Look for "Prompt Manager Text" in the PromptManager/Text category - outputs STRING for any text node
5. **Access the web interface**:
- **Workspace**: click **PM** in ComfyUI's top bar or **Web UI** on a node, or open `http://localhost:8188/prompt_manager/workspace`
- **Legacy pages** still work at their URLs, and System's Overview section links to them:
`/prompt_manager/admin` (dashboard), `/prompt_manager/admin#/tags` (tags), `/prompt_manager/gallery` (gallery), `/prompt_manager/gallery.html` (metadata viewer)
- **Admin Dashboard**: `http://localhost:8188/prompt_manager/admin` - Full management interface
- **Metadata Viewer**: `http://localhost:8188/prompt_manager/metadata.html` - Standalone PNG analysis tool
- **Simple Interface**: `http://localhost:8188/prompt_manager/` - Basic prompt browser
### Prerequisites
@@ -324,10 +324,9 @@ The comprehensive gallery system provides complete access to all your ComfyUI ou
#### **📁 Accessing the Gallery**
1. **Open the workspace's Images page** at `http://localhost:8188/prompt_manager/workspace#/images`
2. **Browse all content** from your ComfyUI output directory
The legacy gallery stays at `http://localhost:8188/prompt_manager/gallery`.
1. **Open the admin interface** at `http://localhost:8188/prompt_manager/admin`
2. **Click the "🖼️ Gallery" button** to open the output-wide gallery
3. **Browse all content** from your ComfyUI output directory
#### **🎬 Video & Image Support**
+45 -405
View File
@@ -53,14 +53,6 @@ stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing i
temp root, when that pipe closes, so a worker killed without teardown leaves no
server behind. A test that fails gets the stderr its server wrote during the test
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
Each worker also records its browser's own log (#513, `tools/vendor/browser-logs.mjs`):
Playwright's `pw:browser` channel, kept as a 300-line tail without printing it
unless `DEBUG` asks for it. A browser that exits abnormally writes one
`pm-browser-exit| browser pid=<n> exited: exitCode=<c>, signal=<s>` line to the job
log, and a test that fails on a closed or crashed target ("Target page, context or
browser has been closed") gets the tail attached as `browser-stderr.txt`, which the
uploaded test-results carry. `tests/browser/browser-logs.spec.mjs` kills a browser of
its own on purpose, so each browser job logs that line once for it.
A `/__test__/.../reset` still running after 2 s writes a report to the job log,
passing test or not: every thread's stack taken while it waits, the default
executor's threads and queue, the open connections and WAL size, then the total
@@ -275,13 +267,11 @@ the process exits non-zero.
use `\\localhost\<missing share>`) or a default TLS context (it reads the
Windows certificate store) costs seconds there; the slowest list shows it.
Every job in `test.yml`, `code-quality.yml`, `viewerjs-check.yml` and
`portability.yml` has `timeout-minutes` (`tests/vendor/ci-workflow.test.mjs`
checks it). The rule is ceil(1.5 x the slowest of three recent green runs on
`main`) + 3 minutes, never under 5, timed as whole-job wall time; the three run
ids, their times and the resulting limits are written in a comment above
`jobs:`. For `portability.yml` the tooling test recomputes the limit from that
comment (#493); its one matrix job takes the slowest Windows or macOS leg. The 3 minutes leave room for one watchdog firing, so
Every job in `test.yml`, `code-quality.yml` and `viewerjs-check.yml` has
`timeout-minutes` (`tests/vendor/ci-workflow.test.mjs` checks it). The rule is
ceil(1.5 x the slowest of three recent green runs on `main`) + 3 minutes, never
under 5; the three run ids, their times and the resulting limits are written
in a comment above `jobs:`. The 3 minutes leave room for one watchdog firing, so
the dump prints before GitHub cancels the job. A job that hits its limit, or a
watchdog dump, is a failure like any other: read the dump, find the blocking
call, fix it. Never raise a limit or rerun to get green; a limit changes only
@@ -763,7 +753,7 @@ filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
content without the cover, then "File & provenance" (the honest file facts, the
`Prompt evidence` line, Download original and, for a listed file, D38's
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
two downloads), then the pair send and Prompt history buttons. D22 words an empty
positive explicitly and makes confirmed prompt text focusable.
@@ -1402,13 +1392,6 @@ seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
Tag) and asserts that every row reserves the tallest footer and that their own footer
is shorter than the row it reserves.
### Overview counts (#547)
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
### Images page controls and recorded deviations (#249 PR C)
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
@@ -1573,27 +1556,20 @@ Recorded deviations on this page (spec §8):
| Result | Toast |
|---|---|
| Success | "Opened the folder on the computer running ComfyUI." |
| 403 `not-same-origin` | "The folder was not opened: this page's address does not match the server's." |
| 501 `unavailable` | "No file manager is available on the computer running ComfyUI." |
| Success | "Opened the folder on this computer." |
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
| 501 `unavailable` | "No file manager is available on this computer." |
| 429 `busy` | "The folder is already opening." |
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
**D176 (#533, beyond the concept):** the viewer is a modal, so `showWorkspaceToast`
moves `#workspace-toast` into the topmost open modal (by hit test when several are
open), keeping its fixed position, and back when that modal closes. A modal opened
after a toast covers it until the next toast. Otherwise every toast raised from a modal is
covered and inert while `toHaveText` still passes: assert `toBeVisible` and the
hit test as `full-inspector.spec.mjs` does.
The route opens the folder on the computer running ComfyUI wherever the browser is,
through a proxy too (maintainer decision, #533, amending the original local-only
rule; accepted risk: anyone who can reach the server can open file windows on it).
It answers 403 `not-same-origin` unless `Origin` is present and matches: its host
and port against `X-Forwarded-Host` when sent, else `Host`, with the scheme's default
port filled in; its scheme against `X-Forwarded-Proto` when sent, else the socket's
(an https page on an http socket passes, TLS having ended at a proxy); and, when
sent, `Sec-Fetch-Site: same-origin`. A body that is not
The route is local-only. It answers 403 `not-local` unless all of these hold: no
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
`null`, another port or another scheme is refused; browsers send `Origin` on every
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
relative path; absolute, drive and `..` paths are 400), the scope goes through
@@ -1610,9 +1586,7 @@ Recorded deviations on this page (spec §8):
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
`xdg-open` on the folder, or answers 501 without `DISPLAY` and `WAYLAND_DISPLAY`.
`open` and `xdg-open` get `OPENER_WAIT` (1 s): a nonzero exit is the 500 above, and
one still running counts as started and goes to the reaper (#533). Those openers are argv lists resolved only on a fixed
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
@@ -1620,11 +1594,12 @@ Recorded deviations on this page (spec §8):
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
closed disclosures as hidden, so the button needs no mapping there.
Limits of that check: a proxy that rewrites `Host` and sends no `X-Forwarded-Host` is
refused with that toast until it sends one. A non-browser client can forge every
header. The check stops a plain cross-site POST, not every other site: a DNS-rebinding
page has a matching `Host` and `Origin`, so it can open a folder of a listed file, at
most once a second. The path check confines it, one check ahead of every opener.
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
from a local client, and a non-browser client behind it can forge a matching
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
screen, at most once a second, and learn whether that file exists (which the
listing routes already tell it). Operators who expose ComfyUI that way accept this.
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
category when set, otherwise the linked positive prompt's, and its recipe's own tags
when it has any, otherwise the positive's; each field falls back on its own and the
@@ -2483,105 +2458,14 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
back records of files that are in the trash (Missing-file links cleans them up). The
legacy gallery lists every file on disk, removed or not. Hard links have one mark per
path, and on FAT/exFAT/SMB a same-size replacement can stay hidden.
Since #501 the thumbnail jobs (System › Thumbnails' counts and its missing, rebuild and
convert runs, and the previews after a scan) and the legacy `GET /scan_output_dir`
listing skip hidden files too; an on-demand preview the legacy gallery asks for is still
made, and a one-image auto-tag (`/autotag/single` + `/autotag/apply`) is an explicit
write that shows the file again. System › Maintenance **Missing-file links** also
forgets the marks that hide nothing any more (the file is gone from a folder that still
exists, or its bytes changed), counted in its scope ("K removed images whose files are
gone or changed") and job line, judged only under a source that is readable and lists
something (an unplugged drive's empty mount point keeps its marks), never for a source
no longer configured, and re-checked at apply (`plan-changed`). The sidebar's Images,
Favorites and Needs review counts and the collection counts are read again when an
`image_delete` job ends in the tab that followed it (`JOB_END_COUNTS`, `state.mjs`).
Tests: `tests/test_image_removal_gaps.py`, the sidebar case in
`tests/browser/workspace-images-delete.spec.mjs`.
- **Trash folders inside an output root** (#501, `utils/media_folders.py`): no walker
descends a folder below a root named `.Trash`, `.Trash-<digits>`, `.Trashes` or
`$RECYCLE.BIN` (any case), as none descends `thumbnails`: the workspace and legacy
listings (`_walk_media`, `_settled_entry`), the metadata scan and duplicate find
(`_collect_media_files`), the legacy auto-tag listing (`_collect_scan_files`) and the
delete review (`trash-folder`), and the image monitor, which tests the folders below the
watched folder holding the new file (a source named like a trash folder is still watched). Tests: `tests/test_media_folders.py`,
`tests/test_image_removal_gaps.py`. Media routes answer not found for a path in such a
folder, resolved first so a link or another spelling cannot pass (#543):
`_media_access_error` is the one gate (by-id, inspect, reveal, link, rescope as
`file-missing`, a prompt's media listing); `/images/serve` and the autotag target ask
`in_trash_below_roots` too, `media_target` refuses by name (annotations, collections,
whose capture store denies links and aliases), and previews are neither made nor served, the fallback past the preview gate's cap
included (`_quick_source`). A History output in one answers `missing`, checked at the
candidate and again at the open (a preview can be a link). The legacy duplicate delete
removes exactly the folder's real path plus the name (a link itself, never its target)
and refuses if either spelling is in a trash folder; `file_problem` refuses too. The delete
and Return flows never use this gate. Windows spellings (trailing dots and spaces, case,
8.3) run for real on the Windows job in `tests/test_trash_gate_routes.py`.
- **An output folder that is a mount point** (#543): the mount's own `.Trash-$uid` and
`.Trash/$uid` are allowed only for a root that is exactly that mount, with the spec's
checks intact. A root above the mount, a mount nested under the root, a source that is or
sits in the trash folder, and a link in place of the trash folder stay refused. Tests:
`tests/test_trash_mount_root.py`, on a fake mount table in the suite and, with
`PM_TRASH_MOUNT=<real mount>`, in portability.yml's `native-trash-linux` job (loop-mounted
ext4). Locally, without root: `unshare -Urm` over a tmpfs.
- **D190** (#543, beyond the concept): a trash review where nothing can move shows a primary
that clearly cannot act: `aria-disabled`, no danger colors (also on hover), and a press does
nothing. Tests: `tests/browser/workspace-images-delete.spec.mjs`.
- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the
freedesktop Trash specification (home trash on the same mount, else the mount's
`.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with
`renameat2(RENAME_NOREPLACE)`; without it, across devices, on a read-only mount or with
a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). macOS
(#499, `utils/trash_macos.py`) calls `-[NSFileManager trashItemAtURL:resultingItemURL:error:]`
through `ctypes` (ten fixed `objc_msgSend` prototypes, nil checks before every send, one
autorelease pool per call, loaded on first use). It answers in the review dialog, without
writing: `network` (not a local volume), `no-trash` (read-only, or no trash folder
macOS would use), `cloud-sync` (iCloud Drive, `~/Library/CloudStorage`, or ubiquitous),
`trash-in-root` (the trash folder is inside an output root, compared by `(st_dev,
st_ino)` of every ancestor) and `no-permission` (folder not writable, an immutable or
append flag, a foreign file in a sticky folder). A fresh volume's `.Trashes/<uid>` is
predicted (`URLForDirectory` answers 3328/`ENOENT` until macOS makes it). A refused move
keeps the file and logs the `NSError`; a move macOS reports done but whose place cannot be
shown (no result URL, or one inside an output root) goes under `errors` as
`trash-unconfirmed`, keeps its records and stops the job. Windows
never uses the Recycle Bin (#500, maintainer ruling (a)): files larger than the bin's
capacity are deleted permanently and IFileOperation's `PreDeleteItem` guard cannot detect
it (native probe runs 40951037575, 40953347904), so every file answers `windows-recycle`
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
browser test server installs it, so no test touches a real trash. The native proof,
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
a read-only image and an unwritable folder (design and probe:
`docs/egm/499-macos-trash/`, local).
- **D162** (#498, maintainer ruling Q1 of #480; the concept has no screen for removed images, so this follows D152's
Show archived prompts): **Show removed images (N)** in the Images heading actions, before Import, a toggle
(`aria-pressed`) shown only while N > 0 or the view is on. N is `removed_total`, every removed image in the selected
source(s) whatever the filters (D152 counts the matching ones). The view (`GET /images/output?mode=workspace&
removed=1`, refused with `favorites`, `collection`, `category`, `tags` or `sort=rating`; a cursor never crosses
views) lists only files a removal mark hides, each card with a "Removed" `.chip` and without the star or Tag action;
the caption reads "N removed images · files on disk, hidden from Images", the empty view "No removed images." /
"No removed images match.". Entering clears Category and Tags; choosing a category or tags while it is on
(toolbar, Refine this view) ends the view. A card's image and Info both inspect (the viewer is
not opened: its Collections… and Delete… would write); the panel shows Return to library in Delete…'s place and
no Collections… or tag-filter chips. The bar reads "N selected", Return to library, Clear. The view is never saved
or restored: leaving the page, Browse or a saved view ends it, and a return that leaves N = 0 ends it too.
- **Return to library** (no review: nothing is lost or moved, as D152's Restore) posts `{items | filter}` to
`POST /prompt_manager/images/removed/return` (only this route takes `filter.removed`; every other bulk route
refuses it). 202 `{job, returned, not_removed}` (400 `no-images` when none is hidden now, `too-many-images` over
500; 409 `busy` beside a scan, restore or delete, nothing changed). The job is a `scan`, "Return images to the
library": its first step deletes, in one transaction, the marks that still hide their file (same size and
`mtime_ns`; the marks generation moves again after the commit), then it scans exactly those files with the scan's
pipeline and groups. Toasts: "Returning N images to the library…", then "Returned N images to the library; K
linked to their prompts. Tags, categories, collections and favorites do not come back." When it ends, the
sidebar's Needs review, Library and Images counts are read again (`JOB_END_COUNTS.scan`).
- What comes back: the file in Images; its prompt, links, pairs and Needs review items from its embedded metadata
(a file without usable metadata comes back unlinked); previews on demand. Not: image tags, auto-tag marks, own
category, collection memberships, favorite (only a safety-backup restore has them). If the server stops between
the mark delete and the scan, the files are back unlinked and a "new files" scan links them.
Tests: `tests/test_image_return.py`, `tests/js/workspace-images-return.test.js`,
`tests/browser/workspace-images-removed.spec.mjs`. A Python change: ComfyUI needs a restart.
a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). Windows
and macOS report `no-trash` until their own backends land; library-only removal works
everywhere. Tests use `tests/fake_trash.py`; the browser test server installs it, so no
test touches a real trash.
### Prompt library cards and recorded deviations (#249 PR E)
@@ -2677,8 +2561,8 @@ applied), D35 (singular captions), D36 (sorted categories), D40 (the continuatio
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
"Current prompt library" link is removed from the shelf; the legacy Library stays at
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open
existing prompt library") and the sidebar still link `/prompt_manager/admin`
(`tests/browser/workspace.spec.mjs`). `library comparison detects
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
@@ -2709,7 +2593,7 @@ Category". It is built only from the concept's Images parts:
- `div#library-bulkbar` sits between the toolbar and the caption, empty until something
is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear (D151
adds Add tags before Set category, in the Images bar's order; D152 adds Delete… before
Clear, and Restore then Delete… on the archived shelf, #492). The
Clear, and Restore alone on the archived shelf). The
selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their
state, Load more keeps it, and any shelf reload (search, category, sort, tags, a
write) and leaving the page clear it. Clicking a checkbox toggles it and sets the
@@ -2846,15 +2730,14 @@ rulings of 2026-10-28):
504: "The server did not confirm the change. The Library was reloaded: check the shelf and
Show archived prompts."; other refusals: "The selected prompts could not be changed. Try
again.", the selection kept.
- **Show archived prompts (N)** (heading actions, before Export, a plain button; N the
- **Show archived prompts (N)** (heading actions, before Export, `aria-pressed`; N the
archived prompts matching the filters, `archived_total`; the shelf counterpart of #235's
Show archived) lists only archived prompts (`GET /prompt_manager/library?shelf=archived`,
a cursor never crosses shelves), each card with an "Archived" `.chip`, the caption "N
archived prompts · M negative variants hidden with them", the bar "N selected", Restore,
Clear, and "No archived prompts." / "No archived prompts match." when empty. On the archived
shelf the button reads the concept's own way back, "← Prompt library" (app.js 594; #482), with
no `aria-pressed` (its label names where it goes). The shelf stays with the shelf visit, never
in a saved view; Browse and a saved view open the active shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
Clear, and "No archived prompts." / "No archived prompts match." when empty. The pressed
state stays with the shelf visit, never in a saved view; Browse and a saved view open the
shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
/prompt_manager/library/restore` (every requested archived id, protected or not): "Restored
N prompts to the Prompt library."
- An archived prompt's page shows the concept's `.notice` ("Archived" / "This prompt is archived
@@ -2882,155 +2765,6 @@ failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-
`tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`,
`tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart.
### Deleting a prompt in a saved pair permanently (#482)
**D160** (beyond legacy, maintainer ruling 2026-10-29; follow-up to D152): when the Delete… selection
holds prompts in a saved pair that no other positive uses as its negative ("deletable"), the review
asks, in a D90 radio row "Prompts in a saved pair" after the grid: **Archive (hidden, restorable)**
(checked on every open, never remembered, so the review is D152's) or **Delete permanently, with
their saved pairs**. Choosing it rewrites the review in place (`openReviewDialog` `setRows`,
`setPrimary`):
- Delete permanently "D prompts: X not in a saved pair, Y in a saved pair" (or "Y prompts in a saved
pair"), Saved pairs removed "S saved pairs (V with a negative prompt, A archived)", Negatives kept
"K negative prompts are kept", Archive "R prompts used as another prompt's negative: hidden,
restorable", then D152's protected, already archived, no longer exist, Image links removed (images
of every deleted prompt) and Backup rows.
- Sentences: the pairs go with their titles, categories, ratings, notes, tags, run counts and image
links; their negative prompts stay; recorded runs stay in History and Activity by their text; D152's
irreversibility (per backup policy) and re-creation sentences, plus "A pair node run with the same
text saves a new pair."; and, when R > 0, why those are archived.
- Primary (`danger`): "Delete D prompts" / "Delete D and archive R prompts"; a short disk keeps it
unavailable (`aria-disabled`) with the disk sentence, while Archive still starts.
The preview adds `deletable` (a subset of `archive`, which keeps every archive id for D152 clients),
`review_digests` (sha256 of each deletable prompt's row and its pairs: negative, archived flag,
metadata, usage, tags, image-pair links), `paired_counts` and `paired_image_links`, and plans the
disk whenever anything could be deleted. The write takes optional `delete_paired_ids` with their
`paired_digests` (both or neither; ids must be reviewed archive ids). In one transaction, after the
archives, each chosen prompt whose digest is unchanged loses its image-pair links, then its own saved
pairs (pair tags cascade; review decisions keep their rows with `pair_id` cleared), then itself exactly
as D152 deletes. Any drift (pair added or edited, new link, pairs gone, now another prompt's negative)
archives it and counts it `changed`. A paired delete is backed up and disk-checked as a delete. The
toast adds "Removed S saved pairs."; "N changed since the review and was not deleted." replaces "…was
kept.". Negatives are never deleted: one no remaining pair uses is listed in the Library afterwards (as
after the legacy pair delete). Legacy still refuses paired prompts (409).
Tests: `tests/test_bulk_library_delete.py` `TestPairedDelete` (classes and counts, the 7896 empty
negative, the row diff against the legacy pair deletes then prompt delete, archive-only, a positive
with its own negative, a self-pair, each drift, validation); `tests/test_library_delete_routes.py`
`TestPairedDeleteRoutes` (preview, paired-only backup, a D152 body, validation, short disk); units
`tests/js/workspace-library-delete.test.js`, `tests/js/workspace-toolbar.test.js` (`setRows`,
`setPrimary`); browser `tests/browser/workspace-library-delete.spec.mjs` (the choice, its grid and
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
change: ComfyUI needs a restart.
### Pair node gives a new prompt its category and tags (#546)
A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that
save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as
another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes
no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550.
Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart.
### Delete… on the archived shelf (#492)
Extends **D160** (coordinator ruling 2026-10-29, cutover item): the archived shelf's bar reads "N selected · Restore ·
Delete… · Clear". Its Delete… review is the permanent delete only, **without** #482's "Prompts in a saved pair" choice
(on this shelf "Archive" would change nothing; Cancel keeps them archived). Title "Delete archived prompts"; the grid
is #482's Delete permanently grid ("Saved pairs removed" and "Negatives kept" only when a selected prompt is in a saved
pair); an archived prompt used as another prompt's negative cannot be deleted and **stays archived** ("Stays archived: R
prompts used as another prompt's negative"); one restored since the shelf was read is "No longer archived (kept)" and
never written; a review with nothing to delete is close-only ("None of the selected prompts can be deleted."). The
primary is "Delete N prompts"; the review opens with focus on Cancel (it is only a permanent delete; `openReviewDialog`'s
`focusCancel`). The same preview, safety backup, review digests, `records` hold and epoch fence apply.
The routes take an optional `shelf` (`"active"`, the default, or `"archived"`; anything else 400). Under it an archived
prompt is classified by its pairs and a prompt without an archive row is `not_archived`; the held write classifies the
same shelf, so a permanent delete is backed up. The client sends only what it deletes. On this shelf the write archives
and rewrites nothing; every reviewed id not deleted counts `changed`, and the answer adds `not_archived` ("N were
restored since the review and kept."). The main shelf's archive sentence now ends "To delete them later or bring them
back, press Show archived prompts, select them and press Delete… or Restore."
Tests: `tests/test_bulk_library_delete.py` TestArchivedShelfDelete, `tests/test_library_delete_routes.py`
TestArchivedShelfDeleteRoutes, `tests/js/workspace-library-delete.test.js` (archived shelf) and
`tests/browser/workspace-library-delete.spec.mjs` (#492 cases). A Python change: ComfyUI needs a restart.
### Delete on a prompt's own page (#481)
**D161** (beyond the concept, coordinator ruling (A) 2026-10-29, open to the maintainer at the live test): the recipe
editor (Edit metadata, the concept's app.js 976 row "Create text revision", "Archive…", Cancel, Save) reads "Create
text revision", **"Archive variant…"** (the selected negative variant's archive, #235, which used the concept's
"Archive…" label) and **"Delete prompt…"** (danger), then Cancel and Save. "Delete prompt…" closes the editor and runs
D152/D160's Delete… review for that one prompt (`reviewPromptDelete`: the same preview, review with the saved-pair
choice, backup and write, fenced by the page's `library_epoch`). Cancel changes nothing and returns focus to Edit
metadata. A delete toasts and opens the Prompt library; an archive (or a drift) toasts and reads the page again, so an
archived prompt shows its Archived notice. The button is disabled while the editor saves or after a restore.
The concept comparison applies D161 in `mapConcept` (`tests/browser/library-editor-canonical-comparison.mjs`): the
concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete prompt…" with the same geometry and tokens.
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
### Card tops, the Reusable tags window and System's width (#530)
Found in the maintainer's test of the cutover branch; each departs from the concept, whose files are not edited. The
comparisons add `CONCEPT_LAYOUT_DEVIATIONS` (`concept-inventory.mjs`, four rules: card margins, System limit, tag header
background and line) to the opened concept page, so the boxes that follow from them are still compared. The new spec pins the
real values and that the unmapped concept keeps its 18px offset.
- **D182**: the second card of a `.two-col` starts level with the first. The concept's `.surface + .surface` 18px margin
also applies inside its grid, which put Categories and Workspace preferences 18px lower. Stacked cards are now one 18px
gap apart instead of 36px. A surface after a two-column row (Backup & export's Recovery policy) touched the row in the
concept and on main; it now sits 18px below it.
- **D183**: Reusable tags scrolls in its own window inside the card (`max(280px, 100dvh - 500px)`), so a long vocabulary no
longer runs the page off screen. The column headers stick to the top of the window, with the surface background and a
drawn line. Load more stays below the window; the end of the list inside the window (`.vocabulary-end`, observed with
the window as root) appends the next page. A new search or sort returns the window to its top. Reaching Load more by
keyboard no longer appends on its own; Enter does. A rename or merge reloads page one, so the window returns to its top.
- **D184**: `.settings-layout` has no 1000px limit, so every System section uses the full content width, as the section
nav and the other pages do.
- **D185**: on touch and at 390px the same window stays shorter than the screen and overscroll chains to the page, so a
swipe outside the window, or past its top, moves the page.
Measured in `workspace-layout-tags-system.spec.mjs`; the swipe case runs in the touch project.
### Prompt page heading on one row (#524)
**D164** (beyond the concept, which never has four heading actions beside a long title): from 951px, the concept's
sidebar breakpoint, the prompt page's heading actions keep one row and the title takes the remaining width, clamped to
two lines with its full text in the DOM and in `title`. Below 951px the concept's wrapping and stacked heading is
unchanged. A short title lays out as the concept's, so the history concept comparison is unaffected. A deleted prompt's
page has no "Prompt history · …" description: it is emptied and hidden while the gone message shows.
### Delete review and prompt-page polish (#512)
Found in the live test of #505/#507/#509. **D163** (beyond the concept, maintainer ruling 2026-10-29: delete was hard to
find on a prompt's page): the prompt page heading ends with a **"⋯" More menu** button after Send to ComfyUI
(`more-menu.mjs`: `aria-haspopup="menu"`, `aria-expanded`; Enter, Space or ArrowDown opens it on its first item, ArrowUp
on its last; arrows, Home and End move; Escape closes it and returns focus to ⋯; Tab, an outside press or a choice
closes it). It holds one danger item, "Delete prompt…", which opens the same review as Edit metadata's Delete prompt…
(D161, kept) and returns focus to ⋯. The menu uses the concept's button, surface, border and toast shadow, opens
right-aligned under ⋯, and opens rightwards where the heading wraps it to the left edge (390px). It is held with Edit
metadata while the page's review or write runs, and disabled on a gone prompt's page.
Also:
- The Delete… review leaves out rows with nothing in them ("Delete permanently: 0 …", "Archive: 0 …"), close-only
reviews included.
- A review opened from a prompt's page is titled "Delete this prompt" or "Archive this prompt" (following the choice),
and its close-only sentence reads "This prompt cannot be deleted or archived."
- An archived prompt that is another prompt's negative is named so: "1 prompt used as another prompt's negative:
hidden, restorable" (or "N prompts in a saved pair (M used as another prompt's negative)"), with the sentence that its
variant stays.
- A prompt page whose prompt no longer exists (404, after a delete or from a stale link) reads "This prompt no longer
exists. It may have been deleted." with "Open the Prompt library", no Retry; Edit metadata and ⋯ are disabled.
- Item 5 of #512 does not fail on the code after #505: the archived shelf's button reads "← Prompt library", and under a
remembered search the heading's count equals what the archived shelf lists. A regression test pins both.
Tests: `tests/js/workspace-library-delete.test.js` (#512) and `tests/browser/workspace-library-delete.spec.mjs` (#512
cases: the menu by mouse, keyboard and at 390px, the gone page, the titles, the count under a search). JS only: no
restart.
### No 1–5 prompt rating in the workspace (#447)
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
@@ -3184,8 +2918,8 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
### Inspect a file page and recorded deviations (#249 local Inspect, PR L2)
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
the legacy metadata page, which stays at its URL
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
the legacy metadata page, which stays reachable from Overview's "Open existing metadata
inspector" card (`/prompt_manager/gallery.html`). The page is the concept's `#metadata`
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
@@ -3463,20 +3197,15 @@ document title "Review" (the concept's), heading "Metadata review" with its line
concept's sentence, then `.gallery-layout`: the queue's concept media cards
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
the item, Tag opens the shared Tag image dialog with the review scope note) and
`section.surface` "Resolve the association" (filename, Tag image, Link to a prompt…,
Sampler context, the chosen path's prompt blocks, Leave for later, Leave unresolved and
primary Apply association). The
`section.surface` "Resolve the association" (filename, Tag image, Sampler context,
the chosen path's prompt blocks, Leave for later and primary Apply association). The
first item is selected on load, as the concept's first image is. Sampler context lists
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
unusable ones with the server's reason and preselects the first workable one
(rulings R2/R3). Apply
unusable ones, preselects the first workable one and ends with "Leave unresolved"
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
under no current root posts without a capture. Each decision and Leave for later opens
the next item and the page stays (#536 overturns ruling R7); Leave for later writes
nothing. Link to a prompt… (#536) records a Library prompt the reviewer chose,
`POST /prompt_manager/review/items/{id}/link`: the capture guards of Apply, the same
one-prompt-per-file rules, and link provenance `kind: "manual-link"` with no prompt
sides, so it never reads as file evidence. A recorded decision re-reads the row's count, as does every metadata scan that ends. A
under no current root posts without a capture. Both actions return to Images (R7); a
recorded decision re-reads the row's count, as does every metadata scan that ends. A
refused decision keeps the selection and shows the server's sentence (the
association conflict's explanation included) as `p.warning-note` in the panel; a
restored storage or a changed queue reads the queue again; a request with no answer
@@ -3491,14 +3220,6 @@ pending count (`GET /prompt_manager/review/items?limit=1`, its `total`).
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
item after the item's line, and in its job view's heading after Download report. The
item's chip still names its state (D54), where the concept's says "Needs attention".
- **D179** (#536, maintainer decision 2026-10-30): "Leave unresolved" is a button beside
Leave for later, not the Sampler context select's last option; a disabled option names
the server's reason.
- **D180** (#536): with nothing to apply, the select, the prompt blocks and Apply are
hidden; the panel gives the path count and each reason, and Link to a prompt… is primary.
- **D181** (#536): the concept has no picker. Link to a prompt… opens the shared content
dialog: Library search, the prompt's recipes as Negative prompt (one preselected,
several must be chosen, always "No negative"), Cancel and Link image.
- **D119**: the queue cards have no selection checkbox and
no star (the page has no selection bar and the queue carries no favorite state); a
file under no current root has an empty, `aria-disabled` image button and Tag; more
@@ -4072,23 +3793,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
defaults" ("Restore this browser’s display defaults, then save. Other settings are
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
- **D170** (#532, maintainer request 2026-10-30, beyond the concept): every viewer has
`button#viewer-keys.icon-btn` right before the D156 view switch (accessible name and
`title` "Keyboard shortcuts", a `command` outline icon, hidden at ≤700px with the
switch). It opens `dialog#viewer-keys-dialog`, a modal built on demand inside the
viewer, so a closed overlay adds nothing to the concept comparisons but the button:
`.dialog-header` with × and the keys grouped as Move (← → Space), Image (+ - 0 R H),
View (F I ? Esc) and File (D, Delete where the viewer has Delete…), one column at
390px. Esc or × closes only the overlay and focus returns to the button; opening it
stops the slideshow. ← and → navigate from anywhere in the viewer, on `document` as the
legacy gallery does, unless a modifier is held, text is selected, another dialog is on
top, or focus is in a video, form field, `pre`, `summary`, or anything in the details
but a button or link. The other keys share that gate but allow Shift, and each clicks
the toolbar's own control, so an aria-disabled, missing or hidden control does nothing
(Rotate on a video, I when Centered, F at ≤700px, Delete without Delete…). `?` opens
the overlay directly. Delete only opens the review. Space on a focused button or link
activates it instead, and only + and - repeat. `workspace-viewer-keys.spec.mjs` pins
this.
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
@@ -4332,24 +4036,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
long file name keeps the concept's two equal columns at phone width.
- **D173** (#535, amends D106; maintainer 2026-10-30): "always asks" now means asks when
there is a choice. One eligible window is selected, and a selected window with exactly
one eligible node selects that node; two or more nodes are never selected. A node whose
requested field is linked is not offered (`linkedRoles` on each destination), and the
status line says which case applies: linked, wrong roles, no node, or no window. It
counts the other nodes that cannot receive, and the review names the node and window.
- **D174** (#535): the window option shows the tab's current title, read at each discovery
(a getter on the host options, which the Toolkit reads per discover), without the old
"PromptManager —" prefix. Same-named windows get " (window 2)", numbered by host id so the
number is stable while the tabs stay loaded. Every node option reads "#<id> <title>", so
identical nodes can be told apart. Discovery waits 300 ms, as System does.
- **D175** (#535, maintainer 2026-10-30): the concept README previews before applying;
production makes Preview optional. Apply is enabled once a window and node are selected
and requests the ticket itself, in one click. It stays closed after "Sent to ComfyUI.",
so a double click sends once, and after an unknown outcome until a new preview or
choice. The host's checks still refuse a linked field or a changed workflow, and the
sender also refuses if the destination's `revision` (node object plus graph version)
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
@@ -4455,15 +4141,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
`system shell and overview match the concept` (the mapping inserts the row) and
`library notice matches the concept notice` with its mutation check
(system-concept.spec), and the D129 tests of workspace-system.spec.
- **D166** (one-time upgrade notice, #527): the concept's README puts the notice in the
new shell, while its `app.js` draws it in System › Sources. Production does both: the
Sources `.notice` is kept and compared as drawn, and `#older-images-notice`, styled as
`#library-banner`, shows the same notice above every other page until dismissed. The
shell hides on Sources, so they never show together. Dismissal is the legacy key
`pm_update_notice_dismissed`, shared with the admin modal and across tabs. Browser
contexts start dismissed (`tests/browser/fixtures.mjs`, `storageState`); a spec about
the notice sets `test.use({ olderImagesNotice: true })`. Pinned by
workspace-older-images-notice.spec.
- **D130** (Appearance, #317): the concept keeps "Thumbnail quality, size limit" in
Appearance (README) but draws no rows for them. Appearance gains a second
`section.surface`, "Thumbnails", after "Browsing & display", built from the concept's
@@ -5741,23 +5418,6 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-backups.spec.mj
--project chromium --project touch --project firefox --project webkit
```
## Sticky chrome (#531)
Beyond the concept, from the maintainer's test of #528; the concept is unchanged. `workspace-sticky-chrome.spec.mjs` holds these.
- **D186:** the selection bars (Images `#bulkbar`, Library `#library-bulkbar`; there are no others) are sticky: under the
top bar at the top of the page, and at the top of the window once that non-sticky top bar has scrolled away (one rule
in workspace.css). While the bar's flow place is above the window, its appearing, resizing or leaving does not move the cards
(`keepingPlace`). Focused card controls and the Images panel keep clear of it (`--selection-bar-block`). At 700px and below
the bar is one row that scrolls sideways. After a bulk action finishes the page scrolls to the top and refreshes the
list; Set category, Delete… and Return to library clear the selection as before, while tagging and collections keep
it (D85, D138). Export metadata changes nothing, so it stays put. Single-image actions from the inspector and viewer stay put.
- **D187:** Back to top: one shell chip past 200px of scroll, above the toast's spot; it rises only for a toast taller than two lines. It never animates, because the
pages' height corrections would cancel a smooth scroll, and focus moves to `#workspace-main`. D42's band grows to 140px.
- **D188:** the Needs review panel is sticky like the Images panel, with its own scroll on a window under 720px tall, and static at 700px and below.
- **D189:** where a panel is one column (static): Info brings the panel into view. On
Images the panel follows the whole list, past infinite scroll, so loading waits for the reader's next move (wheel, touch or key) rather than pushing the panel away.
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
@@ -7170,8 +6830,8 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
## Native vocabulary UI (#236)
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays at
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
longer hands off to the legacy tag manager. That page stays reachable from Overview's
"Open existing tag manager" card for its own page; tag search and sort, the untagged
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
library's selection bar, D151). A prompt's category is changed in the Prompt library
@@ -7293,23 +6953,3 @@ until acceptance. Not compared: hover states and the dialog focus ring (the page
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
floors in `frontend-coverage-baseline.json`.
## Cutover entry points (#525)
The top-bar PM button, both Web UI buttons and `/prompt_manager/web` open
`/prompt_manager/workspace#/library` (`web/js/workspace-entry.js`; `web/index.html` keeps its
own copy, pinned by `tests/js/workspace-entry.test.js`). Overview's cards open native hashes.
The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.html`) stay at
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
ruling; pinned by `legacy-system-links.spec.mjs`).
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
`mapOverviewConcept` adds it to the page tree.
- **Load failure** (maintainer ruling 2026-10-30): `web/workspace.html` holds static
`#workspace-boot-failure` with the same four links, styled inline so it needs no stylesheet.
An inline guard shows it at `load` unless `index.mjs` set `data-workspace-booted`, so a failed
fetch of any module and a throw while starting are both covered; `workspace.spec.mjs` tests
both. Editing `workspace.html` needs a ComfyUI restart, because the server caches it. The PM button has no
page-fixture test; it is proven on the managed host (`tests/comfyui/prompt-manager.spec.mjs`).
+1 -44
View File
@@ -43,7 +43,6 @@ from .image_categories import DELETE_SQL as DELETE_CATEGORY_SQL
from .image_removal_schema import (
META_TABLE,
TABLE,
bump_generation,
clear_marks,
has_marks_table,
mark_of,
@@ -145,23 +144,6 @@ def removed_from_library(conn, path) -> bool:
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())
@@ -320,29 +302,4 @@ class ImageRemovalOperations:
if not locators:
return 0
with self._pair_transaction() as conn:
removed = clear_marks(conn, locators)
# #498: again after the commit, so a reader that cached the marks
# between the in-transaction bump and the commit reloads them.
bump_generation()
return removed
def clear_hiding_marks(self, files) -> set:
"""Delete the marks that still hide their file (#498, Return to library).
*files* are ``(locator, path)`` pairs. In one ``BEGIN IMMEDIATE``
transaction each mark is read again and deleted only while the file at
*path* still has its size and ``mtime_ns``. Returns the locators whose
marks went.
"""
cleared = set()
files = list(files)
if not files:
return cleared
with self._pair_transaction() as conn:
for locator, path in files:
mark = mark_of(conn, locator)
if mark is not None and mark_hides({locator: mark}, locator, path):
cleared.add(locator)
clear_marks(conn, sorted(cleared))
bump_generation()
return cleared
return clear_marks(conn, locators)
-5
View File
@@ -155,11 +155,6 @@ def _bump() -> None:
_generation += 1
def bump_generation() -> None:
"""Move the generation after a commit (a cached read made before it reloads)."""
_bump()
def has_marks_table(conn: sqlite3.Connection) -> bool:
return (
conn.execute(
+14 -198
View File
@@ -13,9 +13,6 @@ 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
@@ -42,34 +39,8 @@ _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",
"not_archived",
"archive",
"delete",
)
# #492: the shelf a Delete… runs on. On the archived shelf an archived prompt is
# classified by its pairs, and one restored since is ``not_archived``.
SHELVES = ("active", "archived")
def _check_shelf(shelf: str) -> bool:
"""Whether ``shelf`` is the archived shelf (ValueError when unknown)."""
if shelf not in SHELVES:
raise ValueError("shelf must be active or archived")
return shelf == "archived"
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
def _batches(ids: list) -> Iterable[list]:
@@ -105,36 +76,26 @@ class LibraryArchiveOperations:
"""PromptDatabase mixin: classify, archive or delete, and restore parents."""
def _classify_library_delete(
self, conn: sqlite3.Connection, prompt_ids: list[int], shelf: str = "active"
self, conn: sqlite3.Connection, prompt_ids: list[int]
) -> dict:
on_archived_shelf = _check_shelf(shelf)
found, deletable = {}, set()
found = {}
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"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED} " # nosec B608
f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
chunk,
):
protected, archived, paired, negative_of_other = row[1:5]
protected, archived, paired = row[1], row[2], row[3]
if protected:
found[row[0]] = "protected"
elif on_archived_shelf and not archived:
found[row[0]] = "not_archived"
elif archived and not on_archived_shelf:
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"
found[row[0]] = "archive" if paired else "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
@@ -176,82 +137,6 @@ class LibraryArchiveOperations:
]
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)
@@ -259,30 +144,23 @@ class LibraryArchiveOperations:
return list(dict.fromkeys(prompt_ids))
def classify_library_delete(
self, prompt_ids: list[int], *, expected_epoch: str, shelf: str = "active"
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``). On the
archived ``shelf`` (#492) archived prompts are classified by their pairs.
``fingerprints`` of the ``delete`` class (``_fingerprints``).
"""
_check_shelf(shelf)
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, shelf)
deletable = classes["deletable"]
classes = self._classify_library_delete(conn, prompt_ids)
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
@@ -313,8 +191,6 @@ class LibraryArchiveOperations:
*,
expected_epoch: str,
candidates: dict | None,
delete_paired: dict | None = None,
shelf: str = "active",
) -> dict:
"""Archive and delete what the user reviewed, all or nothing.
@@ -325,17 +201,7 @@ class LibraryArchiveOperations:
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``.
On the archived ``shelf`` (#492) the ids are archived already: nothing
is archived or rewritten, every reviewed id not deleted (protected or
changed) counts ``changed``, and one restored since ``not_archived``.
"""
on_archived_shelf = _check_shelf(shelf)
_validate_epoch(expected_epoch)
for ids in (archive_ids, delete_ids):
if not isinstance(ids, list) or any(
@@ -343,16 +209,6 @@ class LibraryArchiveOperations:
):
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):
@@ -360,38 +216,18 @@ class LibraryArchiveOperations:
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, shelf)
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
deletes, archives, changed = [], list(classes["archive"]), 0
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"])
if on_archived_shelf:
# Already archived: the rest stay as they are, each one counted.
archives = []
changed = len(prompt_ids) - len(deletes) - len(paired_deletes)
changed -= len(classes["not_found"]) + len(classes["not_archived"])
image_links = self._image_links(conn, [*deletes, *paired_deletes])
image_links = self._image_links(conn, deletes)
now = utc_now_iso()
for pid in archives:
self._repair_hash(conn, pid)
@@ -404,35 +240,15 @@ class LibraryArchiveOperations:
"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])
deleted = delete_prompt_rows(conn, deletes)
return {
"library_epoch": epoch,
"deleted": deleted,
"archived": len(archives),
"already_archived": len(classes["already_archived"]),
"not_archived": len(classes["not_archived"]),
"not_found": len(classes["not_found"]),
"changed": changed,
"image_links": image_links,
"pairs_removed": pairs_removed,
}
def restore_library_prompts(
+25 -149
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,8 +41,6 @@ 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
@@ -198,8 +194,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,90 +296,6 @@ 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)
@@ -528,11 +438,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 +570,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 +604,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 +676,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 +696,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 +727,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 +744,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]],
}
+3 -16
View File
@@ -112,14 +112,8 @@ class PairOperations:
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
fill_new_positive: bool = False,
) -> int:
"""Save an ordered pair atomically; repeat saves preserve its metadata.
``fill_new_positive`` also gives the positive prompt the category and
tags, only if this save inserted its row (#546). An existing prompt
never changes, so a cleared category stays cleared.
"""
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
with self._pair_transaction() as conn:
return self._save_pair(
conn,
@@ -130,7 +124,6 @@ class PairOperations:
tags=tags,
rating=rating,
notes=notes,
fill_new_positive=fill_new_positive,
)
def _save_pair(
@@ -144,7 +137,6 @@ class PairOperations:
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
fill_new_positive: bool = False,
) -> int:
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
if not isinstance(positive_text, str) or not positive_text.strip():
@@ -152,13 +144,8 @@ class PairOperations:
if not isinstance(negative_text, str):
raise ValueError("Negative prompt text must be a string; use '' for empty")
# Validate metadata before any component insertion.
tags = _validate_metadata(title, category, tags, rating, notes)
positive, created = self._pair_component_outcome(conn, positive_text)
if created and fill_new_positive:
conn.execute(
"UPDATE prompts SET category = ? WHERE id = ?", (category, positive)
)
self._sync_prompt_tags(conn, positive, tags or [])
_validate_metadata(title, category, tags, rating, notes)
positive = self._pair_component(conn, positive_text)
negative = (
self._pair_component(conn, negative_text) if negative_text.strip() else None
)
+20 -126
View File
@@ -5,12 +5,8 @@ A decision is one row of ``media_review_decisions`` for one item revision.
ingester's compatible-only rules (``_ingest_resolved_pair_in``) in the same
``_pair_transaction`` as the decision row, the item's new state and revision
and the review index revision; any conflict or later failure rolls all of it
back. ``manual`` (#536) is the same but for a Library prompt the reviewer chose:
its link's provenance names a manual choice and carries no prompt sides, so it
cannot pose as file evidence, and the decision's ``candidate_hash`` (the table
requires one) is the digest of that choice. ``unresolved`` records the decision
and links nothing. No decision counts a run, touches usage or calls an execution
writer.
back. ``unresolved`` records the decision and links nothing. No decision
counts a run, touches usage or calls an execution writer.
Callers verify the file first (the capture must be the item's bytes and the
current file must still hash to them); nothing here reads the filesystem.
@@ -21,7 +17,6 @@ import sqlite3
from .annotation_schema import BYTES_TABLE
from .annotations import _new_id
from .library import _ELIGIBLE, _FROM, SHELVED
from .library_commands import LibraryCommandError
from .models import utc_now_iso
from .review import (
@@ -35,28 +30,9 @@ from .review import (
same_name_rows,
)
from .review_changes import notify_review_change
from .review_schema import (
DECISIONS_TABLE,
ITEMS_TABLE,
canonical_evidence,
evidence_digest,
)
from .review_schema import DECISIONS_TABLE, ITEMS_TABLE, canonical_evidence
DECISION_KIND = "user-selection"
MANUAL_KIND = "manual-link"
_MANUAL_POSITIVE = (
f"SELECT p.id,p.text FROM {_FROM} " # nosec B608
f"WHERE p.id=? AND {_ELIGIBLE} AND {SHELVED}"
)
_MANUAL_PAIR = (
"SELECT COALESCE(n.text,'') AS negative FROM prompt_pairs pp "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE pp.id=? AND pp.positive_prompt_id=? AND pp.archived=0"
)
_ARCHIVED_BARE_PAIR = (
"SELECT 1 FROM prompt_pairs WHERE positive_prompt_id=? "
"AND negative_prompt_id IS NULL AND archived=1"
)
_INSERT_DECISION = (
f"INSERT INTO {DECISIONS_TABLE}(id,item_id,decision_revision," # nosec B608
"created_at,outcome,candidate_hash,evidence,pair_id,image_id) "
@@ -83,8 +59,6 @@ class ReviewCommandError(LibraryCommandError):
``fingerprint`` the current dependency fingerprint, when known;
``changed`` marks an association conflict caused by links that changed
since the reviewer read them (rather than an incompatible choice).
``prompt-missing``: the Library prompt or recipe of a manual link is gone,
archived or not that prompt's.
"""
def __init__(
@@ -119,7 +93,6 @@ class ReviewDecisionOperations:
sha256: str,
outcome: str,
candidate: dict | None = None,
manual: dict | None = None,
image_path: str | None = None,
metadata: dict | None = None,
expected_fingerprint: str | None = None,
@@ -128,9 +101,7 @@ class ReviewDecisionOperations:
) -> dict:
"""Record the decision; return ``{item, total, pair}``.
``selected`` needs the evidence ``candidate`` (with its ``hash``) or
``manual`` (``{positive_id, pair_id}``; a null ``pair_id`` is no
negative), the
``selected`` needs the evidence ``candidate`` (with its ``hash``), the
``image_path`` the scan and the monitor link under, the captured
``metadata`` and the ``expected_fingerprint`` the reviewer saw.
``aliases`` are the other spellings legacy rows of the same physical
@@ -144,12 +115,9 @@ class ReviewDecisionOperations:
if outcome not in ("selected", "unresolved"):
raise ValueError("outcome must be selected or unresolved")
if outcome == "selected" and (
(not isinstance(candidate, dict)) == (not isinstance(manual, dict))
or not image_path
not isinstance(candidate, dict) or not image_path
):
raise ValueError(
"A selected decision needs one candidate or manual choice and a file"
)
raise ValueError("A selected decision needs a candidate and a file")
try:
with self._pair_transaction() as conn:
decided = self._decide_in(
@@ -160,7 +128,6 @@ class ReviewDecisionOperations:
sha256=sha256,
outcome=outcome,
candidate=candidate,
manual=manual,
image_path=image_path,
metadata=metadata or {},
expected_fingerprint=expected_fingerprint,
@@ -191,24 +158,13 @@ class ReviewDecisionOperations:
raise _Rollback(ReviewCommandError("file-changed", epoch))
decision_id = _new_id()
pair = None
selected = command["outcome"] == "selected"
candidate = command["candidate"] if selected else None
manual = command["manual"] if selected else None
candidate_hash = candidate["hash"] if candidate is not None else None
candidate = command["candidate"] if command["outcome"] == "selected" else None
if candidate is not None:
pair = self._apply_candidate(
conn, epoch, current, decision_id, total, **command
)
elif manual is not None:
pair = self._apply_manual(
conn, epoch, current, decision_id, total, **command
)
manual = {"positive_id": manual["positive_id"], "pair_id": pair["id"]}
candidate_hash = evidence_digest(
canonical_evidence({"kind": MANUAL_KIND, **manual})
)
evidence = {
"kind": MANUAL_KIND if manual is not None else DECISION_KIND,
"kind": DECISION_KIND,
"decision_id": decision_id,
"outcome": command["outcome"],
"reviewed_digest": command["sha256"],
@@ -218,8 +174,6 @@ class ReviewDecisionOperations:
"dependency_fingerprint": command["expected_fingerprint"],
"pair": pair,
}
if manual is not None:
evidence["manual"] = manual
now = utc_now_iso()
self._insert_review_decision(
conn,
@@ -229,7 +183,7 @@ class ReviewDecisionOperations:
current["revision"] + 1,
now,
command["outcome"],
candidate_hash,
candidate["hash"] if candidate is not None else None,
canonical_evidence(evidence),
pair["id"] if pair else None,
pair["image_id"] if pair else None,
@@ -250,77 +204,6 @@ class ReviewDecisionOperations:
self, conn, epoch, current, decision_id, total, **command
) -> dict:
"""Link the candidate's pair, compatible-only; raise on any conflict."""
candidate = command["candidate"]
positive, negative = candidate["positive"], candidate["negative"]
provenance = {
"kind": DECISION_KIND,
"decision_id": decision_id,
"reviewed_digest": command["sha256"],
"candidate_hash": candidate["hash"],
"resolver_version": current["resolver_version"],
"positive": positive,
"negative": negative,
}
return self._link_compatible(
conn, epoch, current, total, positive, negative, provenance, command
)
def _apply_manual(self, conn, epoch, current, decision_id, total, **command):
"""Link the chosen Library prompt, compatible-only; raise otherwise."""
missing = _Rollback(
ReviewCommandError("prompt-missing", epoch, item=current, total=total)
)
positive_id = command["manual"]["positive_id"]
pair_id = command["manual"]["pair_id"]
row = conn.execute(_MANUAL_POSITIVE, (positive_id,)).fetchone()
if row is None:
raise missing
positive_text, negative_text = row[1], ""
if pair_id is not None:
chosen = conn.execute(_MANUAL_PAIR, (pair_id, positive_id)).fetchone()
if chosen is None:
raise missing
negative_text = chosen[0]
elif conn.execute(_ARCHIVED_BARE_PAIR, (positive_id,)).fetchone():
raise missing
provenance = {
"kind": MANUAL_KIND,
"decision_id": decision_id,
"reviewed_digest": command["sha256"],
"resolver_version": current["resolver_version"],
"positive_id": positive_id,
"pair_id": pair_id,
}
# The sides name no provenance: only the ingester reads them.
pair = self._link_compatible(
conn,
epoch,
current,
total,
{"text": positive_text},
{"text": negative_text},
provenance,
command,
)
# A prompt whose stored hash is stale reaches another row: never link that.
reached = conn.execute(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair["id"],)
).fetchone()[0]
if reached != positive_id or pair_id not in (None, pair["id"]):
raise missing
return pair
def _link_compatible(
self,
conn,
epoch,
current,
total,
positive,
negative,
provenance,
command,
) -> dict:
# Imported here: pair_ingestion imports operations, which composes this.
from .pair_ingestion import _ingest_resolved_pair_in
@@ -359,6 +242,17 @@ class ReviewDecisionOperations:
# Enrich the one row of this file under the spelling it was
# linked with, so the ingester's compatibility rules see it.
image_path = rows[0][1]
candidate = command["candidate"]
positive, negative = candidate["positive"], candidate["negative"]
provenance = {
"kind": DECISION_KIND,
"decision_id": decision_id,
"reviewed_digest": command["sha256"],
"candidate_hash": candidate["hash"],
"resolver_version": current["resolver_version"],
"positive": positive,
"negative": negative,
}
result = _ingest_resolved_pair_in(
conn,
self,
+2 -8
View File
@@ -18,13 +18,9 @@ protocol and peer checks reject unrelated messages. Same-origin scripts are
trusted; this transport is not authentication against other scripts on that origin.
Unsupported messaging is reported without breaking existing ComfyUI nodes.
Apply works in one click: without a preview, the sender requests the ticket itself
and applies it. Preview remains optional, and shows the exact change first.
An apply timeout or abort after posting means the outcome is unknown. It cannot
prove the host cancelled the write. The sender must inspect a fresh destination
preview or choose the destination again before sending again; automatic retry must
not overwrite edits. An error the host returned is a refusal: nothing was written.
preview before deciding to send again; automatic retry must not overwrite edits.
## PromptManager adapter
@@ -35,9 +31,7 @@ field. Unknown/ambiguous metadata must not become an empty string.
Previews show original and requested text plus positive prepend/append composition.
Connected composition is marked unknown rather than guessing upstream values.
Connected destination fields reject writes. The sender compares the destination's revision
(node object and graph version) from discovery with the one at the preview, and refuses if the workflow changed.
Applying rechecks graph, node, widgets,
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
values and connections. Ordinary serialization cache synchronization is accepted
only when it preserves live values. Both requested roles are checked before writing.
-2
View File
@@ -230,8 +230,6 @@ class PromptManagerPair(PromptManagerFocused):
negative_text,
category=category.strip() or None,
tags=self._parse_tags(tags),
# The Library shows the prompt's own category and tags, not the pair's.
fill_new_positive=True,
)
pair = self.db.get_prompt_pair(pair_id)
prompt_id = pair["positive_prompt_id"]
-3
View File
@@ -78,7 +78,6 @@ from .lora_integration import LoraIntegrationMixin
from .lora_import_routes import LoraImportRoutesMixin
from .image_categories import ImageCategoryRoutesMixin
from .image_delete import ImageDeleteMixin
from .image_return import ImageReturnMixin
from .source_rescope import SourceRescopeMixin
try:
@@ -350,7 +349,6 @@ class PromptManagerAPI(
LoraImportRoutesMixin,
ImageCategoryRoutesMixin,
ImageDeleteMixin,
ImageReturnMixin,
SourceRescopeMixin,
):
"""REST API handler for PromptManager operations and web interface.
@@ -730,7 +728,6 @@ class PromptManagerAPI(
self._register_lora_import_routes(routes)
self._register_image_category_routes(routes)
self._register_image_delete_routes(routes)
self._register_image_return_routes(routes)
self._register_rescope_routes(routes)
# Register gzip compression middleware (once)
+27 -71
View File
@@ -36,21 +36,10 @@ except ImportError:
try:
from ...utils import exit_signal
from ...utils.media_folders import (
in_trash_below_roots,
in_trash_folder,
is_skipped_folder,
)
except ImportError:
from utils import exit_signal
from utils.media_folders import (
in_trash_below_roots,
in_trash_folder,
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,
@@ -71,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,
@@ -130,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 = []
@@ -149,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))
)
@@ -249,12 +236,7 @@ SCAN_FINAL_STATES = {
"error": "failed",
}
# The scope as the scan log's first line names it (the review dialog's words).
SCAN_SCOPE_LOG = {
"new": "new and unresolved files",
"all": "all files",
"returned": "images returned to the library",
}
RETURN_JOB_TITLE = "Return images to the library" # #498
SCAN_SCOPE_LOG = {"new": "new and unresolved files", "all": "all files"}
MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches
# Log line prefix per outcome group (spec: + added, = known, ? review, ! error,
# - no usable metadata).
@@ -911,8 +893,11 @@ 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):
mark = marks.get(os.path.normcase(os.path.realpath(str(path))))
if mark is not None and tuple(mark) == (
stat.st_size,
stat.st_mtime_ns,
):
continue
key = _physical_file_key(path, stat)
if key not in seen:
@@ -1097,21 +1082,13 @@ class AdminRoutesMixin:
return result
def _delete_one_duplicate(self, image_path, output_path):
"""Delete one file and its thumbnail; return a failure reason or None.
What is removed is exactly the folder's real path plus the name: a
link is removed itself, never its target, and the preview removed is
that name's. Both the link's place and what it leads to are checked.
"""
"""Delete one file and its thumbnail; return a failure reason or None."""
file_path = Path(image_path)
if not file_path.is_absolute():
file_path = output_path / file_path
try:
real_root = output_path.resolve()
rel_path = file_path.resolve().relative_to(real_root)
exact = Path(os.path.realpath(file_path.parent)) / file_path.name
exact_rel = exact.relative_to(real_root)
rel_path = file_path.resolve().relative_to(output_path.resolve())
except ValueError:
self.logger.warning(
f"Attempted to delete file outside output directory: {image_path}"
@@ -1120,19 +1097,15 @@ class AdminRoutesMixin:
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
return "not a media file"
if "thumbnails" in rel_path.parts or "thumbnails" in exact_rel.parts:
if "thumbnails" in rel_path.parts:
return "thumbnails are managed by the gallery"
if in_trash_below_roots(file_path, [output_path]) or in_trash_folder(
str(exact_rel)
):
return "it is in a trash folder" # never unlinked from a trash (#543)
if not file_path.is_file():
return "file not found"
os.remove(exact)
os.remove(file_path)
self.logger.info(f"Deleted duplicate image: {image_path}")
_remove_thumbnail(real_root, exact_rel, self.logger)
_remove_thumbnail(output_path.resolve(), rel_path, self.logger)
return None
async def get_statistics(self, request):
@@ -1406,17 +1379,13 @@ class AdminRoutesMixin:
}
)
def _start_scan_job(
self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None, returned=None
):
def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None):
"""Register the scan job synchronously; its runner starts on the loop.
``files`` is the scope (``SCAN_SCOPES``). ``roots`` selects sources by
their index in ``output_dirs`` (the effective sources the caller
resolved); None scans every source, resolved when the job starts.
Raises JobBusy while another record-changing job runs. ``returned``
(#498, ``[(path, locator)]``) makes it "Return images to the library":
those files' removal marks go first, then only they are scanned.
Raises JobBusy while another record-changing job runs.
"""
if files not in SCAN_SCOPES:
raise ValueError(f"Unknown scan scope: {files!r}")
@@ -1424,26 +1393,20 @@ class AdminRoutesMixin:
output_dirs is None or not roots or max(roots) >= len(output_dirs)
):
raise ValueError("A scoped scan needs the sources its roots index")
scope = {"files": files, "roots": None if roots is None else list(roots)}
if returned is not None:
scope = {"files": "returned", "roots": None, "images": len(returned)}
job = self._jobs.start(
SCAN_JOB_KIND,
SCAN_JOB_TITLE if returned is None else RETURN_JOB_TITLE,
SCAN_JOB_TITLE,
record_changing=True,
scope=scope,
runner=functools.partial(
self._run_scan_job, output_dirs=output_dirs, returned=returned
),
scope={"files": files, "roots": None if roots is None else list(roots)},
runner=functools.partial(self._run_scan_job, output_dirs=output_dirs),
groups=SCAN_GROUP_NAMES,
)
self._scan_job = job
return job
async def _run_scan_job(self, job, output_dirs=None, returned=None):
async def _run_scan_job(self, job, output_dirs=None):
final = None
extra = {} if returned is None else {"returned": returned}
async for payload in self._scan_images_events(job, output_dirs, **extra):
async for payload in self._scan_images_events(job, output_dirs):
job.publish(payload)
final = payload.get("type")
state = SCAN_FINAL_STATES.get(final, "finished")
@@ -1481,7 +1444,7 @@ class AdminRoutesMixin:
)
return response
async def _scan_images_events(self, job, output_dirs=None, returned=None):
async def _scan_images_events(self, job, output_dirs=None):
"""Yield progress payloads while scanning the output directories.
Every effective source is walked, or only those ``job.scope["roots"]``
@@ -1519,16 +1482,9 @@ class AdminRoutesMixin:
0, f"Scanning {len(walked)} directory(ies) for media files..."
)
if returned is None:
media_files = await self._run_in_executor(
self._collect_output_media_sync, walked
)
else:
# #498: only the files whose marks this job deleted.
media_files = await self._run_in_executor(
self._clear_returned_marks, returned
)
job.log_line(f"Returned {_plural(len(media_files), 'image')}")
media_files = await self._run_in_executor(
self._collect_output_media_sync, walked
)
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
job.update(
total=len(media_files),
+4 -17
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 in_trash_below_roots, is_skipped_folder
except ImportError:
from utils.media_folders import in_trash_below_roots, is_skipped_folder
from aiohttp import web
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
@@ -849,12 +844,9 @@ class AutotagRoutesMixin:
"""Return the real path when it is a media file inside an output directory."""
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
return None
roots = list(self._get_all_output_dirs())
for output_dir in roots:
for output_dir in self._get_all_output_dirs():
if path_is_within(raw_path, output_dir):
real = os.path.realpath(raw_path)
if in_trash_below_roots(real, roots):
return None # thrown away, not an output (#543)
return real if os.path.isfile(real) else None
return None
@@ -1327,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 = []
@@ -1339,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:
@@ -1366,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 = []
-9
View File
@@ -16,11 +16,6 @@ from pathlib import Path
from .admin import DELETABLE_MEDIA_EXTENSIONS
from .maintenance import DUPLICATE_FILES
try:
from ...utils.media_folders import in_trash_below_roots, is_trash_folder
except ImportError:
from utils.media_folders import in_trash_below_roots, is_trash_folder
# Keep entries one review takes; with paths of a few hundred characters the
# list stays inside the preview body cap (DUPLICATE_PREVIEW_BODY_MAX, 1 MiB).
MAX_KEEP = 2000
@@ -202,8 +197,6 @@ def file_problem(
parts = Path(os.path.relpath(lexical, base)).parts
if "thumbnails" in parts or ".." in parts:
return OUTSIDE_SOURCE
if any(is_trash_folder(part) for part in parts[:-1]):
return OUTSIDE_SOURCE # never removed from a trash folder (#543)
if os.path.splitext(lexical)[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
return OUTSIDE_SOURCE
try:
@@ -219,8 +212,6 @@ def file_problem(
try:
if not _lexically_inside(_canonical_path(path), [_canonical_path(root)]):
return OUTSIDE_SOURCE # a linked folder on the way
if in_trash_below_roots(path, [root]):
return OUTSIDE_SOURCE # a linked folder into a trash folder
if keeper is not None:
same = keeper_problem(path, root, info, keeper, keeper_root)
if same:
-8
View File
@@ -17,7 +17,6 @@ from aiohttp import web
from . import thumbnail_settings
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
from .images import in_trash_below_roots
from .images import existing_media_thumbnail, media_thumbnail_rel
try:
@@ -199,9 +198,6 @@ def resolve_current_candidate(
if namespace is None or not namespace.is_dir():
return _unavailable("root_unavailable")
target = _canonical(lexical)
if in_trash_below_roots(target, [namespace]):
# Every kind and the outputs listing come through here (#543).
return _unavailable("missing")
if not _authorized(target, namespace, scope):
return _unavailable("not_authorized")
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
@@ -252,10 +248,6 @@ def _open_current_target(
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if in_trash_below_roots(target, [namespace]):
# Also at the open: the cover chooses its preview after the candidate
# check, and a preview can be a link into a trash folder (#543).
raise CurrentLocationUnavailable("missing")
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
suffix = target.suffix.lower()
+5 -7
View File
@@ -232,17 +232,15 @@ class ImageCategoryRoutesMixin:
return None, _coded(400, "too-many-images", TOO_MANY)
return tuple(selection.targets), None
async def _category_request(self, request, fields: frozenset, data=None):
async def _category_request(self, request, fields: frozenset):
"""``(data, targets, roots, None)`` or ``(None, None, None, error)``.
The images are ``items`` (1 to 500 targets) or ``filter`` (all images
an Images listing filter admits), besides *fields*. *data* is the body
when the caller has already read it (a body is read once).
an Images listing filter admits), besides *fields*.
"""
if data is None:
data, error = await _json_body_or_error(request)
if error is not None:
return None, None, None, error
data, error = await _json_body_or_error(request)
if error is not None:
return None, None, None, error
names = set(data) - fields
if set(data) & fields != fields or names not in ({"items"}, {"filter"}):
return (
+38 -79
View File
@@ -47,16 +47,12 @@ from .maintenance import MAINTENANCE_PREVIEW_LEASE, _fail_job, _failure, backup_
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")
@@ -65,9 +61,7 @@ TITLES = {
"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"
TRASH_FOLDERS = frozenset({".trash", ".trashes", "$recycle.bin"})
# Reason codes (design §4.4) and the sentence each shows.
REASONS = {
@@ -86,21 +80,6 @@ REASONS = {
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 = {
@@ -114,12 +93,19 @@ SOURCES_CHANGED = "The sources changed since the review; nothing was changed."
RECORDS_FAILED = "The image records could not be removed; the job stopped."
def _is_trash_folder(name: str) -> bool:
folded = name.casefold()
return folded in TRASH_FOLDERS or (
folded.startswith(".trash-") and folded[7:].isdigit()
)
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]):
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"
@@ -341,7 +327,7 @@ class ImageDeleteMixin:
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
changed, failed = 0, False
for done, target in enumerate(targets, start=1):
if failed:
self._record_delete(job, "not_changed", target, "not-attempted")
@@ -350,8 +336,7 @@ class ImageDeleteMixin:
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
failed = True
job.record(
"errors",
{
@@ -359,9 +344,9 @@ class ImageDeleteMixin:
"root": target["root_label"],
"root_index": target["root_index"],
"root_scope": target["root_scope"],
"detail": text,
"detail": RECORDS_FAILED,
},
f"! {target['root_label']}/{target['rel']} · {text}",
f"! {target['root_label']}/{target['rel']} · {RECORDS_FAILED}",
)
elif group == done_group:
changed += 1
@@ -380,10 +365,8 @@ class ImageDeleteMixin:
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.scope["failure"] = "records-failed"
job.publish({"type": "error", "code": "records-failed", "message": line})
job.finish("failed")
@staticmethod
@@ -422,19 +405,7 @@ class ImageDeleteMixin:
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"])
@@ -472,49 +443,37 @@ class ImageDeleteMixin:
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)
}
hidden = set()
for locator, (size, mtime_ns) in marks.items():
entry = located.get(locator)
if entry is None:
continue
try:
info = os.lstat(entry.path)
except OSError:
continue
if (info.st_size, info.st_mtime_ns) == (size, mtime_ns):
hidden.add(entry.normalized)
return hidden
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)
hidden = set()
for path in paths:
mark = marks.get(os.path.normcase(os.path.realpath(str(path))))
if mark is None:
continue
try:
info = os.lstat(path)
except OSError:
continue
if (info.st_size, info.st_mtime_ns) == tuple(mark):
hidden.add(str(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."""
-148
View File
@@ -1,148 +0,0 @@
"""Return removed images to the library (#498).
``POST /prompt_manager/images/removed/return {items | filter}`` names files a
removal mark hides (#480, "Remove from the library only"): ``items`` are 1 to
500 listing identities, ``filter`` the removed view's listing query
(``removed: "1"`` plus the view's other parameters, at most 500 images). Only
this route takes ``removed``: every other bulk route refuses it, because an
explicit write there would show a file again without re-reading it.
The route answers 202 ``{job, returned, not_removed}`` after starting a
``scan`` job titled "Return images to the library" (admission ``records``:
409 ``busy`` beside a scan, restore or delete, nothing changed). The job
first deletes the marks that still hide their file (same size and
``mtime_ns``), in one transaction, then scans exactly those files with the
scan's own pipeline: prompts, links, pairs and Needs review items come back
from the files' embedded metadata. Image tags, auto-tag marks, the own
category, collection memberships and the favorite were deleted by the remove
and do not come back. No file is moved, written or deleted.
"""
import os
from pathlib import Path
from aiohttp import web
from .image_categories import TOO_MANY, _coded
from .images import (
ListingSourceChanged,
_favorite_relative_key,
_json_body_or_error,
parse_listing_filter,
)
from .image_tag_jobs import MAX_JOB_TARGETS
from .jobs import JobBusy
from .job_routes import busy_response
try:
from ...database.image_removal import mark_hides
except ImportError:
from database.image_removal import mark_hides
NONE_REMOVED = "None of the selected images are removed from the library."
SOURCE_CHANGED = "The image sources changed. Reload Images and try again."
def _bad(message):
return web.json_response({"success": False, "error": message}, status=400)
class ImageReturnMixin:
"""Return to library (#498): clear the marks, then scan those files."""
def _register_image_return_routes(self, routes):
# A fixed path beside the dynamic /images/{image_id} routes.
@routes.post("/prompt_manager/images/removed/return")
async def return_removed_images_route(request):
return await self.return_removed_images(request)
async def return_removed_images(self, request):
data, error = await _json_body_or_error(request)
if error is not None:
return error
if set(data) == {"filter"}:
targets, roots, error = await self._removed_filter_targets(data["filter"])
else:
_data, targets, roots, error = await self._category_request(
request, frozenset(), data=data
)
if error is not None:
return error
files = {}
for index in dict.fromkeys(t[0] for t in targets):
files[index] = await self._get_gallery_files(roots[index])
returned, not_removed = await self._run_in_executor(
self._hidden_targets, roots, targets, files
)
if not returned:
return _coded(400, "no-images", NONE_REMOVED)
try:
job = self._start_scan_job(
files="all", output_dirs=list(roots), returned=returned
)
except JobBusy as busy:
return busy_response(busy)
return web.json_response(
{
"success": True,
"job": job.summary(),
"returned": len(returned),
"not_removed": not_removed,
},
status=202,
)
async def _removed_filter_targets(self, raw):
"""``(targets, roots, None)`` for the removed view's filter, else an error."""
if not isinstance(raw, dict) or raw.get("removed") != "1":
return None, None, _bad('filter must hold removed: "1"')
rest = {key: value for key, value in raw.items() if key != "removed"}
try:
parse_listing_filter(rest)
selection = await self.resolve_listing_filter(
{**rest, "removed": "1"}, MAX_JOB_TARGETS
)
except ListingSourceChanged:
return None, None, _coded(409, "source-changed", SOURCE_CHANGED)
except ValueError as exc:
return None, None, _bad(str(exc))
if selection.truncated:
return None, None, _coded(400, "too-many-images", TOO_MANY)
if not selection.targets:
return None, None, _coded(400, "no-images", NONE_REMOVED)
return tuple(selection.targets), list(self._get_all_output_dirs()), None
def _hidden_targets(self, roots, targets, files):
"""``([(path, locator)], not_removed)``: the targets a mark hides now.
Each target is matched to its listed spelling (the walk's). One
physical file named twice (nested roots) is returned once; a target
that is not listed or not hidden counts in ``not_removed``.
"""
marks = self._removal_marks()
listings, seen, returned, not_removed = {}, set(), [], 0
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))
if spelled is None:
not_removed += 1
continue
path = os.path.join(str(root), *spelled.split("/"))
locator = os.path.normcase(os.path.realpath(path))
if locator in seen:
continue
if not mark_hides(marks, locator, path):
not_removed += 1
continue
seen.add(locator)
returned.append((Path(path), locator))
return returned, not_removed
def _clear_returned_marks(self, returned) -> list:
"""The paths whose marks this deleted (blocking; one transaction)."""
cleared = self.db.clear_hiding_marks(
[(locator, str(path)) for path, locator in returned]
)
return [path for path, locator in returned if locator in cleared]
+25 -83
View File
@@ -66,12 +66,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,
in_trash_below_roots,
in_trash_folder,
is_skipped_folder,
)
from ...utils.parallel import map_parallel
from ...utils.json_strict import dumps_strict
from ...utils.inspection_response import (
@@ -85,7 +79,7 @@ try:
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_same_origin_request,
is_local_request,
reveal_executor,
reveal_in_file_manager,
)
@@ -97,12 +91,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,
in_trash_below_roots,
in_trash_folder,
is_skipped_folder,
)
from utils.parallel import map_parallel
from utils.json_strict import dumps_strict
from utils.inspection_response import (
@@ -116,7 +104,7 @@ except ImportError:
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_same_origin_request,
is_local_request,
reveal_executor,
reveal_in_file_manager,
)
@@ -287,20 +275,10 @@ def _forbidden(message="Access denied"):
return web.json_response({"success": False, "error": message}, status=403)
def _file_not_found():
return web.json_response(
{"success": False, "error": "Image file not found"},
status=404,
headers={"Cache-Control": "no-store"},
)
def _media_access_error(path, allowed_dirs):
"""The response refusing *path*, or None when it may be read.
"""Return a 403 response when *path* must not be served, else None.
Fails closed: an empty *allowed_dirs* denies everything (403). A file in a
trash folder below its root is thrown away, not an output: a 404, as if it
were not there (#543), also through a link or another spelling.
Fails closed: an empty *allowed_dirs* denies everything.
"""
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
@@ -308,8 +286,6 @@ def _media_access_error(path, allowed_dirs):
return _forbidden("No allowed image directories configured")
if not any(_is_within(path, root) for root in allowed_dirs):
return _forbidden()
if in_trash_below_roots(path, allowed_dirs):
return _file_not_found()
return None
@@ -817,12 +793,12 @@ def _reveal_not_found():
)
def _not_same_origin():
def _not_local():
return web.json_response(
{
"success": False,
"code": "not-same-origin",
"error": "This page's address does not match the server's.",
"code": "not-local",
"error": "Show in folder works only on the computer running ComfyUI.",
},
status=403,
)
@@ -939,8 +915,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
@@ -952,6 +927,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:
@@ -975,7 +952,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,)))
@@ -999,10 +976,8 @@ 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
if in_trash_below_roots(path, [root]):
return None # a linked folder on the way leads into a trash folder
folder = os.fspath(root)
for part in ("", *folders):
folder = os.path.join(folder, part) if part else folder
@@ -1184,10 +1159,7 @@ def _workspace_filters(query: Mapping) -> dict:
the linked positive, and ``favorites=1`` keeps only favorites (the
Favorites page, #249 D51). ``collection=<id>`` (1 to 9 digits) keeps only
that collection's members (#331); it wins over ``favorites`` (a page never
sets both). ``removed=1`` lists only the files a removal mark hides (#498,
Show removed images); such a file has no records, so it never combines
with ``favorites``, ``collection``, ``category``, ``tags`` or the rating
sort. Raises ValueError for any unknown or oversized value.
sets both). Raises ValueError for any unknown or oversized value.
"""
text = query.get("q", "").strip()
folded = text.casefold()
@@ -1202,12 +1174,6 @@ def _workspace_filters(query: Mapping) -> dict:
)
favorites = query.get("favorites", "0")
collection = query.get("collection", "")
removed = query.get("removed", "0")
if removed not in ("0", "1") or (
removed == "1"
and (favorites == "1" or collection or category or tags or order == "rating")
):
raise ValueError("Invalid gallery filters")
if (
favorites not in ("0", "1")
or (collection != "" and _COLLECTION_ID.fullmatch(collection) is None)
@@ -1235,7 +1201,6 @@ def _workspace_filters(query: Mapping) -> dict:
"tags": tags,
"favorites": favorites == "1" and not collection,
"collection": int(collection) if collection else None,
"removed": removed == "1",
}
@@ -1538,7 +1503,7 @@ class ImageRoutesMixin:
async def clear_image_favorite_route(request):
return await self.update_image_favorite(request, favorite=False)
# POST, same-origin: opens the server computer's file manager (D38).
# POST, local-only: opens this computer's file manager (D38).
@routes.post("/prompt_manager/images/reveal")
async def reveal_image_route(request):
return await self.reveal_image(request)
@@ -2107,7 +2072,6 @@ class ImageRoutesMixin:
filters["tags"],
filters["favorites"],
filters["collection"],
filters["removed"],
]
).encode()
).hexdigest()
@@ -2137,11 +2101,6 @@ class ImageRoutesMixin:
self._select_workspace_page, listings, filters, after, limit, scope
)
)
# #498: Show removed images (N), every removed file in the selected
# source(s) whatever the filters (the hidden set is cached work).
removed_total = len(
await self._run_in_executor(self._hidden_entries, listings)
)
except CollectionMissing:
return collection_not_found()
except Exception:
@@ -2168,7 +2127,6 @@ class ImageRoutesMixin:
"limit": limit,
"has_more": has_more,
"next_cursor": next_cursor,
"removed_total": removed_total,
}
if positive_count is _COUNT_OMITTED:
del result["positive_count"]
@@ -2492,12 +2450,9 @@ class ImageRoutesMixin:
def key(entry):
return _workspace_key(entry, order)
# #480: files removed from the library stay on disk, hidden here; the
# removed view (#498) lists only them.
# #480: files removed from the library stay on disk, hidden here.
hidden = self._hidden_entries(listings)
if filters.get("removed"):
entries = [entry for entry in entries if entry.normalized in hidden]
elif hidden:
if hidden:
entries = [entry for entry in entries if entry.normalized not in hidden]
return (
entries,
@@ -2785,9 +2740,6 @@ class ImageRoutesMixin:
if _has_traversal(filepath):
return _forbidden()
suffix = Path(filepath).suffix.lower()
if in_trash_folder(filepath):
# Before any file access, so no preview is made for it (#543).
return _file_not_found()
if suffix not in SERVABLE_EXTENSIONS and not (
# A JPEG XL preview is served from thumbnails/ only (#317).
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
@@ -2811,8 +2763,6 @@ class ImageRoutesMixin:
# a fall-through to the next root.
if not _is_within(image_path, output_path):
return _forbidden()
if in_trash_below_roots(image_path, output_dirs):
return _file_not_found()
if image_path.is_file():
return self._file_response(image_path)
@@ -2864,9 +2814,8 @@ class ImageRoutesMixin:
if _is_thumbnail_cache_path(filepath):
return _forbidden()
image_path = root / filepath
denied = _media_access_error(image_path, [root])
if denied is not None:
return denied
if not _is_within(image_path, root):
return _forbidden()
if not image_path.is_file():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
@@ -3126,9 +3075,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)
@@ -3137,14 +3084,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)
@@ -3712,12 +3656,12 @@ class ImageRoutesMixin:
async def reveal_image(self, request):
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
Same-origin and JSON-only, directly or through a proxy: the folder
opens on the computer running ComfyUI, wherever the browser is
(maintainer decision, #533). One reveal at a time (429 busy).
Local-only (a same-origin browser request from a loopback client, no
forwarding header) and JSON-only: the browser must run on the
computer whose file manager opens. One reveal at a time (429 busy).
"""
if not is_same_origin_request(request.headers, request.scheme):
return _not_same_origin()
if not is_local_request(request.remote, request.headers, request.scheme):
return _not_local()
if request.content_type != "application/json":
# A non-JSON POST is a CORS "simple" request with no preflight.
return web.json_response(
@@ -3795,9 +3739,7 @@ class ImageRoutesMixin:
{
"success": False,
"code": "unavailable",
"error": (
"No file manager is available on the computer running ComfyUI."
),
"error": "No file manager is available on this computer.",
},
status=501,
)
+11 -69
View File
@@ -47,10 +47,6 @@ 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"})
# #492: the shelf the Delete… runs on, optional on its own (default "active").
_SHELF_KEYS = frozenset({"shelf"})
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
@@ -68,52 +64,19 @@ def _storage_changed(exc):
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
async def _body(request, keys, *groups):
"""The JSON object with exactly ``keys``, plus all or none of each group."""
async def _body(request, keys):
"""The JSON object with exactly ``keys``, or raise ValueError."""
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
partial = False
for group in groups:
if extra & group:
partial = partial or not group <= extra
extra -= group
if not keys <= set(data) or extra or partial:
if set(data) != keys:
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
return data
def _shelf(data):
"""#492: the shelf a Delete… was reviewed on."""
shelf = data.get("shelf", "active")
if shelf not in ("active", "archived"):
raise ValueError("shelf must be active or archived")
return shelf
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 == []:
@@ -134,9 +97,8 @@ class LibraryDeleteRoutesMixin:
async def preview_library_delete(self, request):
try:
data = await _body(request, _SELECTION_KEYS, _SHELF_KEYS)
data = await _body(request, _SELECTION_KEYS)
ids = parse_bulk_prompt_ids(data["prompt_ids"])
shelf = _shelf(data)
except ValueError as exc:
return _invalid(str(exc))
db = self.db
@@ -147,10 +109,9 @@ class LibraryDeleteRoutesMixin:
db.classify_library_delete,
ids,
expected_epoch=data["expected_epoch"],
shelf=shelf,
)
disk = None
if (review["delete"] or review["deletable"]) and policy == "on":
if review["delete"] and policy == "on":
public, short, _size = await self._run_in_executor(
_maintenance.disk_plan,
db.model.db_path,
@@ -182,16 +143,8 @@ class LibraryDeleteRoutesMixin:
"archive": review["archive"],
"protected": review["protected"],
"already_archived": len(review["already_archived"]),
"not_archived": len(review["not_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,
}
@@ -199,8 +152,7 @@ class LibraryDeleteRoutesMixin:
async def delete_library_prompts(self, request):
try:
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS, _SHELF_KEYS)
shelf = _shelf(data)
data = await _body(request, _DELETE_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:
@@ -211,7 +163,6 @@ class LibraryDeleteRoutesMixin:
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")
@@ -222,7 +173,7 @@ class LibraryDeleteRoutesMixin:
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
) as job:
response = await self._held_library_delete(
self.db, data, archive_ids, delete_ids, paired, shelf
self.db, data, archive_ids, delete_ids
)
if response.status >= 400:
job.finish("failed")
@@ -230,25 +181,18 @@ class LibraryDeleteRoutesMixin:
except JobBusy as busy:
return busy_response(busy)
async def _held_library_delete(
self, db, data, archive_ids, delete_ids, paired, shelf="active"
):
async def _held_library_delete(self, db, data, archive_ids, delete_ids):
epoch = data["expected_epoch"]
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
backup = None
try:
candidates = None
if delete_ids or paired:
if delete_ids:
review = await self._run_in_executor(
db.classify_library_delete,
[*delete_ids, *paired],
expected_epoch=epoch,
# #492: the same shelf, or archived ids would skip the backup.
shelf=shelf,
db.classify_library_delete, delete_ids, 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:
if candidates and promised:
_disk, short, _size = await self._run_in_executor(
_maintenance.disk_plan,
db.model.db_path,
@@ -270,8 +214,6 @@ class LibraryDeleteRoutesMixin:
delete_ids,
expected_epoch=epoch,
candidates=candidates if candidates is not None else {},
delete_paired=paired,
shelf=shelf,
)
except LibraryCommandError as exc:
return _storage_changed(exc)
+3 -9
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"],
@@ -658,7 +653,6 @@ class MaintenanceRoutesMixin:
plan.fingerprint,
payload["targets"],
payload["roots"],
payload.get("marks", ()),
)
except PlanChanged:
return _fail_job(job, "plan-changed", PLAN_CHANGED)
@@ -670,7 +664,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)}")
+2 -8
View File
@@ -9,15 +9,13 @@ by the caller.
import os
import re
from .images import _file_not_found, _forbidden, _has_traversal
from .images import _forbidden, _has_traversal
from .prompts import bad_request
try:
from ...utils.media_capture import CAPTURE_EXTENSIONS
from ...utils.media_folders import in_trash_folder
except ImportError:
from utils.media_capture import CAPTURE_EXTENSIONS
from utils.media_folders import in_trash_folder
MAX_TARGET_PATH = 1024
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
@@ -31,9 +29,7 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
Only the listing identity is accepted: an int root index, the root's
64-hex scope and a bounded root-relative POSIX media path, never an
absolute, drive-qualified, backslash or ``..`` path (a 400 for a malformed
shape, a 403 for an unsafe or non-media path, carrying *media_message*, a
404 for a path in a trash folder, #543). A link or a short-name spelling
is refused later by the capture store's alias check.
shape, a 403 for an unsafe or non-media path, carrying *media_message*).
"""
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
return None, bad_request("target must name root_index, root_scope and path")
@@ -55,6 +51,4 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
return None, _forbidden()
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
return None, _forbidden(media_message)
if in_trash_folder(relative):
return None, _file_not_found()
return (index, scope, relative), None
+1 -6
View File
@@ -102,12 +102,7 @@ class PairRoutesMixin:
validate_prompt_text(negative)
fields = metadata_fields(data)
pair_id = await self._run_in_executor(
self.db.save_prompt_pair,
positive,
negative,
# The node's Save pair button: as a run, a new prompt gets the metadata.
fill_new_positive=True,
**fields,
self.db.save_prompt_pair, positive, negative, **fields
)
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
return web.json_response({"success": True, "pair": pair})
+50 -59
View File
@@ -9,10 +9,7 @@ candidates and the dependency fingerprint. ``POST .../items/{id}/decision``
records one explicit decision: Apply links the chosen sampler's pair only
when the capture is the item's bytes, the current file still hashes to them,
the evidence they yield is unchanged and the ingester's compatible-only rules
allow it; Leave unresolved links nothing. ``POST .../items/{id}/link`` (#536)
links a Library prompt the reviewer chose, under the same capture and
compatibility guards; it is their choice, so what the bytes resolve to is not
asked.
allow it; Leave unresolved links nothing.
Targets are formatted from the recorded spelling against the current roots
(no file access beyond the roots' real paths); a file under no current root
@@ -31,13 +28,12 @@ import urllib.parse
from aiohttp import web
from .annotations import _capture_error, _error
from .annotations import _HEX64, _TOKEN, _UUID, _capture_error, _error
from .images import media_thumbnail_url
from .inspection_service import VIDEO_EXTENSIONS
from .job_routes import busy_response
from .jobs import JobBusy
from .prompts import bad_request
from .review_guards import UNRESOLVED, _decision_guards, _link_guards
try:
from ...database.library import display_title
@@ -63,6 +59,17 @@ MAX_CURSOR = 4096
MAX_BODY_BYTES = 64 * 1024
# The serialized detail stays under this; past it the detail is ``limited``.
MAX_DETAIL_BYTES = 8 * 1024 * 1024
UNRESOLVED = "unresolved"
_DECISION_FIELDS = frozenset(
{
"expected_epoch",
"expected_revision",
"capture_token",
"sha256",
"dependency_fingerprint",
"choice",
}
)
_CURSOR_SECRET = os.urandom(32)
# The real path of a spelling; module-level so a test can fake a junction.
_realpath = os.path.realpath
@@ -77,7 +84,6 @@ _STATUS = {
"storage-changed": 409,
"source-changed": 409,
"capture-expired": 409,
"prompt-missing": 409,
}
_MESSAGES = {
"review-missing": "This review item no longer exists. Reload the queue.",
@@ -89,9 +95,6 @@ _MESSAGES = {
"sampler path cannot be attached safely. The existing links were "
"preserved."
),
"prompt-missing": (
"That prompt or recipe is no longer in the Library. Search again."
),
"dependencies-changed": (
"The image's saved links changed since this item was opened. Reopen it "
"to see the current association."
@@ -248,7 +251,6 @@ class ReviewRoutesMixin:
routes.get(ITEMS_URL)(self.list_review_items)
routes.get(ITEMS_URL + "/{item_id}")(self.get_review_item)
routes.post(ITEMS_URL + "/{item_id}/decision")(self.decide_review_item)
routes.post(ITEMS_URL + "/{item_id}/link")(self.link_review_item)
# -- shared ----------------------------------------------------------------
@@ -462,43 +464,6 @@ class ReviewRoutesMixin:
except JobBusy as busy:
return busy_response(busy)
async def link_review_item(self, request: web.Request) -> web.Response:
"""POST the Library prompt the reviewer chose for one pending item.
Under the same lease as a decision. The capture must be the item's
current bytes; the choice and the file's links are checked in the
decision's transaction.
"""
try:
async with self._jobs.lease(REVIEW_DECISION_LEASE):
return await self._link_review_item(request)
except JobBusy as busy:
return busy_response(busy)
async def _link_review_item(self, request: web.Request) -> web.Response:
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
if error is not None:
return error
guards, error = _link_guards(data)
if error is not None:
return error
try:
item = await self._run_in_executor(
self.db.review_item, request.match_info["item_id"]
)
except Exception:
self.logger.exception("Review item read failed")
return _failed()
if item is None:
return _coded("review-missing")
verified, error = await self._verified_review_capture(
item, guards, need_metadata=True
)
if error is not None:
return error
manual = {"positive_id": guards["positive_id"], "pair_id": guards["pair_id"]}
return await self._record_decision(item, guards, None, verified, manual)
async def _decide_review_item(self, request: web.Request) -> web.Response:
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
if error is not None:
@@ -584,25 +549,22 @@ class ReviewRoutesMixin:
return None, _coded("file-changed")
metadata = None
if need_metadata:
# A manual link has no ``choice``: it rests on the bytes alone.
if captured is None or (
"choice" in guards
and not _candidate_still_offered(captured[1], guards["choice"])
if captured is None or not _candidate_still_offered(
captured[1], guards["choice"]
):
return None, _coded("review-conflict", "evidence-changed")
metadata = captured[0]
image_path = os.path.join(str(root), *relative.split("/"))
return (capture, facts, image_path, metadata), None
def _decide_sync(self, item, guards, candidate, verified, manual=None):
def _decide_sync(self, item, guards, candidate, verified):
"""Worker thread: resolve the file's spellings, decide, build the view.
Returns ``(result, view)``; a refusal raises with ``exc.view`` set.
"""
_capture, facts, image_path, metadata = verified or (None, None, None, None)
aliases, known = (), None
selected = candidate is not None or manual is not None
if selected:
if candidate is not None:
aliases, known = physical_spellings(self.db, image_path, item["locator"])
try:
result = self.db.decide_review_item(
@@ -611,9 +573,8 @@ class ReviewRoutesMixin:
expected_revision=guards["expected_revision"],
# The bytes the re-hash proved, or the body's for Leave unresolved.
sha256=facts.sha256 if facts is not None else guards["sha256"],
outcome="selected" if selected else UNRESOLVED,
outcome="selected" if candidate is not None else UNRESOLVED,
candidate=candidate,
manual=manual,
image_path=image_path,
metadata=metadata,
expected_fingerprint=guards["dependency_fingerprint"],
@@ -628,11 +589,11 @@ class ReviewRoutesMixin:
roots, real_roots = self._review_roots()
return result, self._review_view(result["item"], roots, real_roots)
async def _record_decision(self, item, guards, candidate, verified, manual=None):
async def _record_decision(self, item, guards, candidate, verified):
capture = verified[0] if verified else None
try:
result, view = await self._run_in_executor(
self._decide_sync, item, guards, candidate, verified, manual
self._decide_sync, item, guards, candidate, verified
)
except ReviewCommandError as exc:
return self._decision_refused(exc)
@@ -668,6 +629,36 @@ class ReviewRoutesMixin:
return _coded(exc.code, message, **extra)
def _decision_guards(data: dict):
"""The validated decision fields, or ``(None, error)``."""
if set(data) != _DECISION_FIELDS:
return None, bad_request(
"Request must contain exactly " + ", ".join(sorted(_DECISION_FIELDS))
)
epoch, revision = data["expected_epoch"], data["expected_revision"]
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
return None, bad_request("expected_epoch must be a canonical UUID")
if type(revision) is not int or revision < 0:
return None, bad_request("expected_revision must be a non-negative integer")
token = data["capture_token"]
if token is not None and (
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
):
return None, bad_request("Invalid capture token")
for name in ("sha256", "dependency_fingerprint"):
value = data[name]
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
return None, bad_request(
f"{name} must be 64 lowercase hexadecimal characters"
)
choice = data["choice"]
if not isinstance(choice, str) or (
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
):
return None, bad_request("choice must be a candidate hash or unresolved")
return {**data}, None
def _verify_and_read(store, capture, item, root, need_metadata: bool):
"""Worker thread: ``(relative, facts, captured)``, or None off the root.
-66
View File
@@ -1,66 +0,0 @@
"""Body validation of Needs review decisions and manual links (#249 E3, #536)."""
from .annotations import _HEX64, _TOKEN, _UUID
from .prompts import bad_request
UNRESOLVED = "unresolved"
_GUARD_FIELDS = frozenset(
{
"expected_epoch",
"expected_revision",
"capture_token",
"sha256",
"dependency_fingerprint",
}
)
_DECISION_FIELDS = _GUARD_FIELDS | {"choice"}
_LINK_FIELDS = _GUARD_FIELDS | {"positive_id", "pair_id"}
def _guard_error(data: dict, fields: frozenset):
"""The first problem with a decision's shared fields, as a response."""
if set(data) != fields:
return bad_request("Request must contain exactly " + ", ".join(sorted(fields)))
epoch, revision = data["expected_epoch"], data["expected_revision"]
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
return bad_request("expected_epoch must be a canonical UUID")
if type(revision) is not int or revision < 0:
return bad_request("expected_revision must be a non-negative integer")
token = data["capture_token"]
if token is not None and (
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
):
return bad_request("Invalid capture token")
for name in ("sha256", "dependency_fingerprint"):
value = data[name]
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
return bad_request(f"{name} must be 64 lowercase hexadecimal characters")
return None
def _decision_guards(data: dict):
"""The validated decision fields, or ``(None, error)``."""
error = _guard_error(data, _DECISION_FIELDS)
if error is not None:
return None, error
choice = data["choice"]
if not isinstance(choice, str) or (
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
):
return None, bad_request("choice must be a candidate hash or unresolved")
return {**data}, None
def _link_guards(data: dict):
"""The validated manual link fields, or ``(None, error)``."""
error = _guard_error(data, _LINK_FIELDS)
if error is not None:
return None, error
if data["capture_token"] is None:
return None, bad_request("A link needs a capture of the file")
positive, pair = data["positive_id"], data["pair_id"]
if type(positive) is not int or positive < 1:
return None, bad_request("positive_id must be a positive integer")
if pair is not None and (type(pair) is not int or pair < 1):
return None, bad_request("pair_id must be a positive integer or null")
return {**data}, None
+2 -9
View File
@@ -41,13 +41,6 @@ def _coded(status: int, code: str, message: str) -> web.Response:
)
def _missing_if_not_found(response: web.Response) -> web.Response:
"""A file in a trash folder answers as a missing file does (#543)."""
if response.status == 404:
return _coded(404, "file-missing", "Image file not found")
return response
def _no_store(response: web.Response) -> web.Response:
response.headers["Cache-Control"] = "no-store"
return response
@@ -78,7 +71,7 @@ class SourceRescopeMixin:
path = root / relative
denied = _media_access_error(path, [root])
if denied is not None:
return _missing_if_not_found(denied)
return denied
try:
mtime = path.stat().st_mtime if path.is_file() else None
except OSError:
@@ -96,7 +89,7 @@ class SourceRescopeMixin:
return bad_request("Send exactly one target")
target, error = media_target(data["target"])
if error is not None:
return _missing_if_not_found(error)
return error
old_index, scope, relative = target
if old_index > MAX_ROOT_INDEX:
return bad_request("Invalid gallery root")
+1 -6
View File
@@ -67,7 +67,6 @@ from aiohttp import web
from . import thumbnail_settings
from .images import (
in_trash_below_roots,
MEDIA_EXTENSIONS,
THUMBNAIL_CLEAR_TITLE,
VIDEO_EXTENSIONS,
@@ -225,8 +224,6 @@ def _quick_source(root, parts):
for name in (base + extension, base + extension.upper()):
path = folder / name
if _regular_file(path) and _is_within(path, root):
if in_trash_below_roots(path, [root]):
return None # also the fallback past the gate's cap (#543)
return path, extension in VIDEO_EXTENSIONS
return None
@@ -251,8 +248,6 @@ def _thumbnail_source(root, parts):
found = _scan_folder(root.joinpath(*folder_parts), base, set(wanted))
if found is None or not _is_within(found, root):
return None
if in_trash_below_roots(found, [root]):
return None
rel = Path(*folder_parts, found.name)
dst = root / media_thumbnail_rel(rel)
if dst != _current_preview(root, parts):
@@ -840,7 +835,7 @@ class AutoThumbnailMixin:
if not folded.startswith(base) or not _is_within(path, root):
continue
rel = Path(os.path.relpath(path, root))
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
if "thumbnails" in rel.parts[:-1]:
return None
dst = root / media_thumbnail_rel(rel)
if not _is_within(root / "thumbnails", root) or not _is_within(
-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>`);
});
+1 -23
View File
@@ -7,7 +7,7 @@ test.beforeEach(async ({page, browserName}) => {
page.classicErrors = [];
page.on('pageerror', error => page.classicErrors.push(error.message));
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => (window.fixtureApiBase ?? "") + path, queuePrompt: async (...args) => args};'}));
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(() => {
@@ -64,28 +64,6 @@ test('Classic keeps immediate selection, search query and metadata serialization
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
});
test('Classic Web UI opens the workspace Library in the saved display mode, through the mount prefix (#525)',async({page})=>{
await page.evaluate(()=>{window.fixtureApiBase='/mounted';window.open=(...args)=>{window.openArgs=args;return {};};});
const webUi=page.getByRole('button',{name:'🌐 Web UI',exact:true});
for (const [mode,args] of [
['newtab',['/mounted/prompt_manager/workspace#/library','_blank']],
['popup',['/mounted/prompt_manager/workspace#/library','_blank','width=1200,height=800,scrollbars=yes,resizable=yes']],
]) {
await page.evaluate(value=>{node.properties.webuiDisplayMode=value;},mode);
await webUi.click();
expect(await page.evaluate(()=>window.openArgs)).toEqual(args);
}
});
test('Classic Web UI reports a blocked popup and keeps the canvas open (#525)',async({page})=>{
const before=page.url();
await page.evaluate(()=>{window.open=()=>null;});
await page.getByRole('button',{name:'🌐 Web UI',exact:true}).click();
await expect(page.getByText('Allow popups to open Web UI, then try again.')).toBeVisible();
expect(page.url()).toBe(before);
await expect(page.locator('.pm-classic')).toBeVisible();
});
test('Classic detaches native text synchronization on removal',async({page})=>{
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
+1 -16
View File
@@ -111,23 +111,9 @@ async function selectTheme(page,theme) {
}
/** The concept at `#<route>` with its own Theme select. */
/**
* The recorded layout deviations (#530), applied to the opened concept page and never to its files, so every box that
* follows from them (wider columns, wrapped text, row heights) is compared rather than excused path by path.
* Every other measurement still differs if production moves. workspace-layout-tags-system.spec.mjs pins the real values
* and that the concept itself still has the old ones.
* D182: the second `.two-col` card starts level, and a surface after a row is 18px below it. D184: no 1000px limit on System. D183: the sticky tag header's background and drawn line.
*/
export const CONCEPT_LAYOUT_DEVIATIONS=`.two-col > .surface + .surface { margin-top: 0; }
.two-col + .surface { margin-top: 18px; }
.settings-layout { max-width: none; }
.two-col > .surface > .table-wrap thead th { background: var(--ca-surface); border-bottom-color: transparent; }`;
export const mapConceptLayout=page=>page.addStyleTag({content:CONCEPT_LAYOUT_DEVIATIONS});
export async function openConcept(page,route,theme,motion='no-preference') {
await page.emulateMedia({reducedMotion:motion});
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
await mapConceptLayout(page);
await selectTheme(page,theme);
await expect(page.locator('#main #page-title')).toBeVisible();
}
@@ -435,8 +421,7 @@ export function measureInventory(scope) {
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
actions:when(actions,()=>({...layout(actions),...edges(actions),
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
// D163: a closed menu's items are not heading actions.
buttons:[...actions.querySelectorAll('button')].filter(node=>!node.closest('[role="menu"]')).map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
}
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
+1 -25
View File
@@ -4,32 +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`.
// The one-time older-images notice (#527) shows above every workspace page and as
// a modal on the admin page until dismissed, so every context starts with it
// dismissed. A spec about the notice sets `test.use({ olderImagesNotice: true })`.
// `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 {
@@ -47,11 +28,6 @@ export const test = base.extend({
if (crash) throw crash;
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
baseURL: async ({ workerServer }, use) => use(workerServer.url),
olderImagesNotice: [false, { option: true }],
storageState: async ({ baseURL, olderImagesNotice }, use) => use(olderImagesNotice ? undefined : {
cookies: [],
origins: [{ origin: new URL(baseURL).origin, localStorage: [{ name: 'pm_update_notice_dismissed', value: '3.2.0' }] }],
}),
});
export { expect };
+1 -1
View File
@@ -485,7 +485,7 @@ test(`${variant} respects the existing Web UI display preference`,async({page})=
});
await page.evaluate(()=>node._focusedSettingsReady);
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/workspace#/library','_blank']);
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
});
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
+6 -59
View File
@@ -362,14 +362,10 @@ test('Show in folder asks the server to open the listed file and announces each
const body=sent.postDataJSON();
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
// #533: the answer is really on screen and reachable above the modal viewer, not covered by it.
await expect(toast).toBeVisible();
expect(await toast.evaluate(node=>{const box=node.getBoundingClientRect();const top=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);return top===node;})).toBe(true);
await expect(page.locator('#viewer-dialog').getByRole('status').filter({hasText:'Opened the folder on the computer running ComfyUI.'})).toHaveCount(1);
await expect(toast).toHaveText('Opened the folder on this computer.');
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
expect(await opened()).toEqual(['output/inspector/complete.png']);
for (const [outcome,message] of [['unavailable','No file manager is available on the computer running ComfyUI.'],['fail','The folder could not be opened. Try again.']]) {
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
await show.click();await expect(toast).toHaveText(message);
}
@@ -377,56 +373,11 @@ test('Show in folder asks the server to open the listed file and announces each
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
await show.click();await expect(toast).toHaveText('The folder is already opening.');
await page.unroute('**/prompt_manager/images/reveal');
// A proxied click opens the folder: the proxy's headers, not the browser's address, name the page (#533).
// A proxied request counts as remote, so the real route answers 403 not-local.
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
await show.click();await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
expect(await opened()).toHaveLength(2);
await page.unroute('**/prompt_manager/images/reveal');
// A plain cross-site POST is refused, and the toast says so.
// Chromium keeps its own Origin on a routed fetch, so the real route is asked directly and the toast gets its answer.
const refused=await request.post('/prompt_manager/images/reveal',{data:body,headers:{origin:'https://evil.example'}});
expect(refused.status()).toBe(403);expect((await refused.json()).code).toBe('not-same-origin');
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:403,contentType:'application/json',body:JSON.stringify({success:false,code:'not-same-origin',error:"This page's address does not match the server's."})}));
await show.click();await expect(toast).toHaveText("The folder was not opened: this page's address does not match the server's.");
await expect(toast).toBeVisible();
expect(await opened()).toHaveLength(2);
// #533: closing the viewer takes the toast back to the page, still showing the message.
await page.keyboard.press('Escape');await expect(page.locator('#viewer-dialog')).toHaveCount(0);
await expect(toast).toBeVisible();await expect(page.locator('body > #workspace-toast')).toHaveCount(1);
});
// #533 D176: real modals in the real top layer, with the page's own toast module.
test('a toast stays on screen across nested, switched and closing modals',async({page,request})=>{
await fullFixture(page,request);
await page.evaluate(async()=>{
const url=performance.getEntriesByType('resource').map(entry=>entry.name).find(name=>name.includes('/toast.mjs'));
const {showWorkspaceToast}=await import(url);
window.say=showWorkspaceToast;
window.modal=(id,first=false)=>{const dialog=document.createElement('dialog');dialog.id=id;dialog.textContent=id;document.body[first?'prepend':'append'](dialog);dialog.showModal();return dialog;};
window.onTop=()=>{const node=document.getElementById('workspace-toast');const box=node.getBoundingClientRect();return box.width>0&&document.elementFromPoint(box.x+box.width/2,box.y+box.height/2)===node;};
});
const parent=()=>page.evaluate(()=>{const host=document.getElementById('workspace-toast')?.parentElement;return host?host.id||host.localName:null;});
const onTop=()=>page.evaluate(()=>window.onTop());
await page.evaluate(()=>{window.modal('a');say('one');});
expect(await parent()).toBe('a');expect(await onTop()).toBe(true);
// Nested: the top one holds it, even earlier in the page than the one under it.
await page.evaluate(()=>{window.modal('b',true);say('two');});
expect(await parent()).toBe('b');expect(await onTop()).toBe(true);
// The top one closes while the toast shows: the message stays, in the modal now on top.
await page.evaluate(()=>document.getElementById('b').close());
await expect.poll(parent).toBe('a');expect(await onTop()).toBe(true);
await expect(page.locator('#workspace-toast')).toHaveText('two');
// Switched: the next modal takes the next toast.
await page.evaluate(()=>{document.getElementById('a').close();window.modal('c');say('three');});
expect(await parent()).toBe('c');expect(await onTop()).toBe(true);
// Closed and removed at once, as the workspace's dialogs do: home, still showing.
await page.evaluate(()=>{const c=document.getElementById('c');c.close();c.remove();});
await expect.poll(parent).toBe('body');expect(await onTop()).toBe(true);
await expect(page.locator('#workspace-toast')).toHaveText('three');
// Removed while open, with no close event: the next message still shows.
await page.evaluate(()=>{window.modal('d');say('four');document.getElementById('d').remove();say('five');});
expect(await parent()).toBe('body');expect(await onTop()).toBe(true);
await expect(page.locator('#workspace-toast')).toHaveText('five');
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
expect(await opened()).toEqual(['output/inspector/complete.png']);
});
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
@@ -464,7 +415,7 @@ test('full inspector preserves long role text and exact sends',async({page,reque
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair Exact pair'});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]);
} finally {await host.evaluate(()=>window.bridge.dispose());await host.close();}
@@ -676,10 +627,6 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
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));
// #532 D170: production's shortcuts button sits right before the view switch; the concept has none.
const keysPaths=production.paths.filter(path=>path.includes('button#viewer-keys'));
expect(keysPaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-keys.icon-btn','button#viewer-keys.icon-btn > svg.icon','button#viewer-keys.icon-btn > svg.icon > path']);
production.paths=production.paths.filter(path=>!keysPaths.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);
@@ -107,7 +107,7 @@ export async function exerciseCurrentPairSend(page, roles) {
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click();
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#canonical-pair Canonical destination' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
+4 -8
View File
@@ -26,8 +26,7 @@ const SECTIONS=['heading','history','noPageScroll'];
*/
export function mapHistoryConcept(reference) {
const {history}=reference.oracle;
// D163 (#512): the heading as laid out with the More menu button (the oracle's heading).
return {...reference,heading:reference.oracle.heading,history:{...history,
return {...reference,history:{...history,
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
summary:history.summary,
@@ -121,14 +120,11 @@ async function conceptAsRecorded(page) {
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
// D163 (#512): the heading ends with the "⋯" More menu button, drawn as the concept's Edit metadata button.
const edit=[...document.querySelectorAll('#main > .heading .heading-actions > button')].find(node=>node.textContent.trim()==='Edit metadata');
const moreButton=edit.cloneNode(false);moreButton.removeAttribute('data-action');moreButton.textContent='⋯';edit.parentElement.append(moreButton);
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived,moreButton};
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
},SHOW_ARCHIVED);
await settle(page);
return ()=>page.evaluate(()=>{
const {timeline,order,texts,archived,moreButton}=window.__historyOracle;moreButton.remove();
const {timeline,order,texts,archived}=window.__historyOracle;
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text;
archived.remove();delete window.__historyOracle;
@@ -194,7 +190,7 @@ function productionSent(host) {
return async page=>{
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
const roles=await host.evaluate(()=>window.previewed.pop());
+1 -4
View File
@@ -474,10 +474,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
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']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
// D186: the panel's sticky offset clears the shown bar; at one column the bar is one sideways row (its items are checked above).
const stuckPanel={...oracle,panel:{...oracle.panel,...(width<=700?{top:actual.panel.top}:{}),box:{...oracle.panel.box,top:actual.panel.box.top}}};
expect(parseFloat(actual.panel.box.top)).toBeGreaterThanOrEqual(20);
expect.soft(compare(stuckPanel,actual,manifest,width<=700?['panel']:['bulkbar','cards','panel']),'one card 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.
await mutate(page,{css:'.bulkbar { padding: 4px }'});
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
@@ -1,65 +0,0 @@
import {test,expect} from './fixtures.mjs';
import {seedConceptAt,openConcept,openProduction} from './concept-inventory.mjs';
// #498 (D162): the concept has no Show removed images screen, so production builds it from the concept's own parts:
// the heading action button (as the concept's Images heading button), the card's `.chip` (as a concept card chip) and
// the selection bar's buttons (as the concept bar's plain buttons). Each is compared with its concept counterpart.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
const KEYS=['color','backgroundColor','borderTopColor','borderTopWidth','fontSize','fontWeight','paddingTop','paddingLeft',
'borderTopLeftRadius','height'];
const CHIP_KEYS=['color','backgroundColor','borderTopColor','fontSize','fontWeight','paddingTop','paddingLeft',
'borderTopLeftRadius'];
/** Runs in the page: the computed style of the first node `selector` matches. */
function styleOf([selector,keys]) {
const node=document.querySelector(selector);
return node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
}
async function measure(page,selectors) {
const found={};
for (const [name,selector,keys] of selectors) found[name]=await page.evaluate(styleOf,[selector,keys]);
return found;
}
test.beforeEach(async({page,request})=>{await seedConceptAt(page,request);});
for (const theme of ['dark','light']) {
test(`Show removed images uses the concept's heading button, chip and bar buttons (${theme})`,async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConcept(page,'images',theme);
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.mouse.move(0,0);
const reference=await measure(page,[
['heading','#main .heading-actions > button:not(.ca-primary)',KEYS],
['chip','#main .chip',CHIP_KEYS],
['bar','#main #bulkbar > .bulkbar > button:not(.danger)',KEYS],
]);
await openProduction(page,'images',theme);
await expect(page.locator(CARDS).first()).toBeVisible();
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Delete…',exact:true}).click();
const dialog=page.locator('dialog#modal[open]');
await dialog.getByRole('radio',{name:/^Remove from the library only/}).check();
await dialog.locator('.dialog-actions button.danger').click();
const toggle=page.locator('.heading-actions').getByRole('button',{name:/^Show removed images \(1\)$/});
await expect(toggle).toBeVisible();
await page.mouse.move(0,0);
const heading=await measure(page,[['heading','#workspace-main .heading-actions > button[aria-pressed]',KEYS]]);
await toggle.click();
await expect(page.locator(`${CARDS} .chip`,{hasText:'Removed'})).toBeVisible();
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await expect(page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Return to library'})).toBeVisible();
await page.mouse.move(0,0);
const actual={...heading,...await measure(page,[
['chip',`${CARDS} .media-info .chip`,CHIP_KEYS],
['bar','#workspace-main #bulkbar > .bulkbar > button',KEYS],
])};
// Every part was found on both sides: two missing nodes must never compare equal.
expect(Object.values(reference).every(Boolean)).toBe(true);
expect(actual).toEqual(reference);
});
}
+1 -1
View File
@@ -259,7 +259,7 @@ async function productionSendOf(page,context) {
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender).toContainText('Sent to ComfyUI.');
-13
View File
@@ -1,13 +0,0 @@
// A job's end toast follows the job, which has no time limit: under load it outlasts the 5 s expect default (#533, as
// #520). Do `action`, whose POST to `url` answers with the job it started, then wait for that job's event stream to end.
export const APPLY_URL = '/prompt_manager/maintenance/apply';
export const RETURN_URL = '/prompt_manager/images/removed/return';
export async function startAndAwaitJob(page, action, url = APPLY_URL) {
const applied = page.waitForResponse(response => response.url().endsWith(url));
await action();
const { job } = await (await applied).json();
const events = await page.request.get(`/prompt_manager/jobs/${job.id}/events`);
if (!events.ok()) throw new Error(`Job ${job.id} events answered ${events.status()}`);
return job;
}
+2 -4
View File
@@ -233,7 +233,7 @@ function productionSent(host) {
return async page=>{
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
const roles=await host.evaluate(()=>window.previewed.pop());
@@ -374,9 +374,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
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']);
// D186: at one column the bar is one row that scrolls sideways, with the same buttons.
const mapped=width<=700?{...reference,bar:{...reference.bar,flexWrap:'nowrap',buttons:reference.bar.buttons.map((button,index)=>({...button,left:actual.bar.buttons[index].left,top:actual.bar.buttons[index].top}))}}:reference;
expect(actual).toEqual(mapped);
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);
@@ -88,16 +88,15 @@ function mapDeleteConcept({ danger, review }) {
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'],
tree: ['dl.meta-grid', '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. To delete them later or bring them back, press Show archived prompts, select them '
+ 'and press Delete… or Restore.' },
+ '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 '
@@ -26,15 +26,9 @@ export function inventoryOf(dialog) {
};
}
// 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];
}
// concept's "Rating" select is not drawn.
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,fields:reference.fields.filter(item=>item.label!=='Rating'),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))
+3 -4
View File
@@ -33,7 +33,7 @@ async function chooseDestination(page) {
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
await host.selectOption({label:'Test ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
return sender;
}
@@ -165,8 +165,7 @@ test('host apply rejection retains the current text and requires a new preview',
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
// D175: a refusal wrote nothing, so Apply may try again.
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
@@ -194,7 +193,7 @@ test('empty negative send clears only the negative destination and preserves its
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=dialog(page);await expect(sender).toContainText('No ComfyUI window answered in this browser');
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
const host=await fakeHost(context,page);
try {
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
+2 -6
View File
@@ -26,10 +26,6 @@ async function openReview(page, theme) {
await openProduction(page, 'review', theme);
await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
await settle(page);
// D179, D181: the two buttons the concept lacks are pinned here, then left out of the comparison.
await expect(panel(page).getByRole('button', { name: 'Leave unresolved', exact: true })).toBeVisible();
await expect(panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true })).toBeVisible();
await page.addStyleTag({ content: '.review-panel .review-leave-unresolved, .review-panel .review-link-open { display: none !important; }' });
}
async function settle(page) {
await page.mouse.move(0, 0);
@@ -94,7 +90,7 @@ for (const [width, height] of SIZES) for (const theme of THEMES) {
expect(surface.children[1].text).toBe(CONCEPT_FILE);
const actions = surface.children.at(-1);
expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]);
expect(node(surface, 'select').options.map(option => option[0])).not.toContain('Leave unresolved'); // D179
expect(node(surface, 'select').options.at(-1)).toEqual(['Leave unresolved', false]);
expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text))
.toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']);
expect(actual.noPageScroll).toBe(true);
@@ -173,7 +169,7 @@ test('the comparison notices a dropped notice border and reordered actions', asy
const reordered = await productionInventory(page);
expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([
'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary']));
await panel(page).locator('.dialog-actions').evaluate(row => row.append(row.firstElementChild));
await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild));
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
});
-3
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
@@ -756,8 +755,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)
+3 -11
View File
@@ -33,8 +33,8 @@ function measureSystem(mainSelector) {
const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect();
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
};
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. D182 (#530)
// levels the second `.two-col` card, on both sides through mapConceptLayout; the real values are in workspace-layout-tags-system.spec.
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. This pins the
// concept's `.surface + .surface` 18px offset, which concept.css applies inside `.two-col` too (copied, not ruled).
const top = node => {
const rect = node.getBoundingClientRect(), previous = node.previousElementSibling?.getBoundingClientRect();
if (previous && rect.top >= previous.bottom) return Math.round(rect.top - previous.bottom);
@@ -165,8 +165,6 @@ const number = value => value.toLocaleString('en-US');
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
// D165: the discreet legacy-pages line follows the settings layout.
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
const rows = {
@@ -259,7 +257,7 @@ const UNMAPPED_PATHS = [
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
'save.button.cursor', 'save.button.opacity', 'save.status.text',
].sort();
test('system comparison detects mutations', async ({ page, request }) => {
@@ -685,8 +683,6 @@ async function watchedSources(request) {
for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) {
const variant = notice ? 'with' : 'without';
test.describe(() => {
test.use({ olderImagesNotice: notice });
test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const server = await watchedSources(request);
@@ -697,11 +693,8 @@ for (const theme of THEMES) for (const [width, height] of SIZES) for (const noti
expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
});
}
test.describe(() => {
test.use({ olderImagesNotice: true });
test('sources comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const server = await watchedSources(request);
@@ -744,7 +737,6 @@ test('sources comparison detects mutations', async ({ page, request }) => {
await undoToken();
expect(diffInventory(expected, await measure())).toEqual([]);
});
});
/**
* Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title,
@@ -88,7 +88,7 @@ function measureIntegrations(mainSelector) {
};
}
const SEND_REASON = 'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.';
const SEND_REASON = 'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.';
const RENDERER_REASON = 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.';
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
const RETURN_REASON = 'Not available: sending applies the text in ComfyUI and keeps this page open.';
@@ -231,9 +231,6 @@ export function mapConceptReview(reference,actual) {
const actualActions=findNode(actual.main,'div.dialog-actions');
let cardIndex=0;
let main={...reference.main,id:actual.main.id};
// D188: the Resolve panel is sticky.
const actualPanel=findNode(actual.main,'div.gallery-layout')?.children[1];
if(actualPanel)main=editNodes(main,'div.gallery-layout',layout=>({...layout,children:layout.children.map((child,index)=>index===1?{...child,position:actualPanel.position}:child)}));
let noticeLoss=0;
main=editNodes(main,'div.notice',notice=>{
const chip=notice.children.find(child=>child.tag==='span.chip');
@@ -282,7 +279,7 @@ export function mapConceptReview(reference,actual) {
});
main=editNodes(main,'select',select=>{
const twin=findNode(actual.main,'select');
return select.id==='review-context'?{...select,options:twin.options}:select; // D179: no Leave unresolved option
return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select;
});
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
-2
View File
@@ -1,6 +1,5 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {mapConceptLayout} from './concept-inventory.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,compareVocabularyPage} from './vocabulary-canonical-comparison.mjs';
@@ -117,7 +116,6 @@ const PREVIEW='“Landscapes” is used by 3 positive prompts and 2 images. Appl
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
async function openConceptRoute(page,route,theme) {
await page.goto('about:blank');await page.goto(`${concept}#${route}`);
await mapConceptLayout(page);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
}
/** The concept page as D143 records it: image counts after the prompt counts, image-only rows before Manage category. */
+2 -14
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);
@@ -486,7 +480,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,8 +499,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();
});
@@ -764,11 +757,6 @@ test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', as
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');
// #532: the arrows move through the collection from the first moment.
await page.keyboard.press('ArrowRight');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_02.png');
await page.keyboard.press('ArrowLeft');
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++; };
+1 -3
View File
@@ -231,8 +231,6 @@ test('every ordered receipt remains selected through distinct current locations,
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
// #532: the arrows work over the recorded run from the first moment.
await page.keyboard.press('ArrowRight');await expect(viewer).toContainText('2 of 2 loaded');await page.keyboard.press('ArrowLeft');await expect(viewer).toContainText('1 of 2 loaded');
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
@@ -470,7 +468,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#history-pair History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
+2 -83
View File
@@ -1,7 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
import { startAndAwaitJob } from './job-end.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).
@@ -63,7 +62,7 @@ for (const mode of ['library', 'trash']) {
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 startAndAwaitJob(page, () => primary.click());
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);
@@ -74,33 +73,6 @@ for (const mode of ['library', 'trash']) {
});
}
// #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 startAndAwaitJob(page, () => primary.click());
await expect(toast(page)).toContainText('Removed 1 image from the library.');
await expect(badge).toHaveText((before - 1).toLocaleString('en-US'));
});
// #533: the toast says "started" for as long as the job's end has not reached the page, then says how it ended.
test('a slow job keeps its started toast until the end arrives', async ({ page }) => {
await openImages(page);
await check(page, 0);
let release;
const held = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/jobs/*/events', async route => { await held; await route.continue(); });
const { primary } = await deleteChecked(page, 'library');
await primary.click();
await expect(toast(page)).toHaveText('Delete images (remove from the library) started.');
release();
await expect(toast(page)).toContainText('Removed 1 image from the library.');
});
test('Cancel changes nothing', async ({ page }) => {
const before = await openImages(page);
await check(page, 0);
@@ -184,66 +156,13 @@ test('without a chosen mode nothing can start a delete', async ({ page }) => {
expect(applies).toEqual([]);
});
// #543 (D190): a review where nothing can move shows a primary that clearly cannot act: no danger colors, ignored presses.
test('a trash review with nothing to move has a clearly disabled primary', async ({ page }) => {
const before = await openImages(page);
await check(page, 0);
await page.route('**/images/delete/preview', async route => {
const response = await route.fetch();
const plan = await response.json();
const reason = "This drive's trash is inside an output folder.";
await route.fulfill({ response, json: {
...plan,
trash: { count: 0 },
trash_refused: { count: 1, counts: { 'trash-in-root': 1 }, rows: [{ name: 'a.png', code: 'trash-in-root', detail: reason }] },
} });
});
const applies = [];
page.on('request', request => { if (request.url().includes('/maintenance/apply')) applies.push(request.url()); });
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = review(page);
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /^Also move the files to the trash/ }).check();
const primary = dialog.locator('.dialog-actions button.danger');
await expect(primary).toHaveText('Move 0 images to the trash');
await expect(primary).toHaveAttribute('aria-disabled', 'true');
await expect(primary).toHaveCSS('cursor', 'not-allowed');
const look = element => { const style = getComputedStyle(element); return [style.color, style.borderTopColor]; };
const cancel = dialog.getByRole('button', { name: 'Cancel', exact: true });
const [color, border] = await primary.evaluate(look);
const [cancelColor] = await cancel.evaluate(look);
const red = await page.evaluate(() => {
const probe = document.createElement('i');
probe.style.color = 'var(--red)';
document.querySelector('.ca-tools').append(probe);
const value = getComputedStyle(probe).color;
probe.remove();
return value;
});
expect(color).not.toBe(red);
expect(border).not.toBe(red);
expect(color).not.toBe(cancelColor);
await primary.hover({ force: true });
await expect(primary).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await primary.click({ force: true });
await primary.focus();
await page.keyboard.press('Enter');
await expect(dialog).toBeVisible();
expect(applies).toEqual([]);
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await expect(primary).not.toHaveAttribute('aria-disabled', 'true');
await expect(primary).toHaveCSS('color', red);
await cancel.click();
await expect(page.locator(CARDS)).toHaveCount(before);
});
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 startAndAwaitJob(page, () => primary.dblclick());
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);
@@ -1,144 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
import { RETURN_URL, startAndAwaitJob } from './job-end.mjs';
// #498 (D162): Images › Show removed images (N) lists the files "Remove from the library only" hid; Return to library
// (bar or inspector) clears their marks and scans them again. Nothing is deleted or moved.
test.use({ locale: 'en-US', timezoneId: 'UTC' });
const CARDS = '#workspace-main article[data-image-key]';
const bar = page => page.locator('#workspace-main #bulkbar');
const toast = page => page.locator('#workspace-toast');
const toggle = page => page.locator('.heading-actions').getByRole('button', { name: /^Show removed images/ });
const imagesBadge = page => page.locator('#primary-nav a[data-route="images"] .nav-count');
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConceptAt(page, request);
page.removedErrors = [];
page.on('pageerror', error => page.removedErrors.push(error.message));
page.on('dialog', dialog => { page.removedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.removedErrors).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;
}
/** Remove the first `count` cards from the library (library mode), then wait for the cards to leave. */
async function removeFromLibrary(page, count) {
const keys = [];
for (let index = 0; index < count; index += 1) keys.push(await check(page, index));
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = page.getByRole('dialog', { name: /^Delete \d+ images?$/ });
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await startAndAwaitJob(page, () => dialog.locator('.dialog-actions button.danger').click());
await expect(toast(page)).toContainText(`Removed ${count} image${count === 1 ? '' : 's'} from the library.`);
for (const key of keys) await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
return keys;
}
test('without removed images there is no toggle', async ({ page }) => {
await openImages(page);
await expect(page.locator('#result-count')).toContainText(/images?/);
await expect(toggle(page)).toHaveCount(0);
});
test('Show removed images lists the removed files; Return to library brings them back', async ({ page }) => {
const before = await openImages(page);
const keys = await removeFromLibrary(page, 2);
await expect(toggle(page)).toHaveText('Show removed images (2)');
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false');
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator(CARDS)).toHaveCount(2);
await expect(page.locator('#result-count')).toHaveText('2 removed images · files on disk, hidden from Images');
for (const key of keys) {
const card = page.locator(`${CARDS}[data-image-key="${key}"]`);
await expect(card.locator('.chip', { hasText: 'Removed' })).toBeVisible();
// A removed file has no records: no star and no Tag action, which would write and show it again.
await expect(card.locator('.star-button')).toHaveCount(0);
await expect(card.locator('.image-card-actions button')).toHaveCount(1);
}
await check(page, 0);
await check(page, 1);
const actions = bar(page).locator('button');
await expect(actions).toHaveText(['Return to library', 'Clear']);
await startAndAwaitJob(page, () => bar(page).getByRole('button', { name: 'Return to library' }).click(), RETURN_URL);
await expect(toast(page)).toContainText('Returned 2 images to the library');
await expect(toast(page)).toContainText('Tags, categories, collections and favorites do not come back.');
// The view emptied: it turns off and Images lists every file again.
await expect(toggle(page)).toHaveCount(0);
await expect(page.locator(CARDS)).toHaveCount(before);
for (const key of keys) await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(1);
await expect(bar(page)).toBeEmpty();
});
test('Return to library from the inspector', async ({ page }) => {
await openImages(page);
const [key] = await removeFromLibrary(page, 1);
await toggle(page).click();
const card = page.locator(`${CARDS}[data-image-key="${key}"]`);
await card.getByRole('button', { name: /^Inspect / }).click();
const panel = page.locator('#image-inspector');
await expect(panel.getByRole('button', { name: 'Return to library' })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Delete…' })).toHaveCount(0);
await expect(panel.locator('button[data-collections]')).toHaveCount(0);
await startAndAwaitJob(page, () => panel.getByRole('button', { name: 'Return to library' }).click(), RETURN_URL);
await expect(toast(page)).toContainText('Returned 1 image to the library');
await expect(toggle(page)).toHaveCount(0);
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(1);
});
test('the sidebar Images count follows a remove and a return', async ({ page }) => {
await openImages(page);
await expect(imagesBadge(page)).toHaveText(/^\d[\d,]*$/);
const start = Number((await imagesBadge(page).textContent()).replace(/,/g, ''));
await removeFromLibrary(page, 1);
await expect(imagesBadge(page)).toHaveText((start - 1).toLocaleString('en-US'));
await toggle(page).click();
await check(page, 0);
await startAndAwaitJob(page, () => bar(page).getByRole('button', { name: 'Return to library' }).click(), RETURN_URL);
await expect(toast(page)).toContainText('Returned 1 image to the library');
await expect(imagesBadge(page)).toHaveText(start.toLocaleString('en-US'));
});
test('the removed view is never saved and ends when the page is left', async ({ page }) => {
await openImages(page);
await removeFromLibrary(page, 1);
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page).toHaveURL(/#\/library/);
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('#result-count')).not.toContainText('removed');
});
test('choosing a category in the removed view ends the view and lists the category', async ({ page }) => {
await openImages(page);
await removeFromLibrary(page, 1);
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
// A removed file has no category: the view ends instead of refusing the listing.
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(page.locator('#result-count')).not.toContainText('removed');
});
+1 -2
View File
@@ -602,8 +602,7 @@ async function staleSourceRecovery(page, request, roots) {
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
// limit=1 is a sidebar count read (Overview re-reads it on a visit, #547), not a page of images.
if (url.pathname.endsWith('/images/output') && url.searchParams.get('limit') !== '1') pages.push(url);
if (url.pathname.endsWith('/images/output')) pages.push(url);
if (url.pathname.includes('/images/inspect/')) inspections.push(url);
if (request.method() !== 'GET') mutations.push(url.pathname);
});
+1 -1
View File
@@ -975,7 +975,7 @@ async function sendPair(page) {
await rail(page).getByRole('button', { name: /^Send prompt pair to ComfyUI: / }).click();
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Inspect ComfyUI' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#pair-1 Inspect destination' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Inspect destination' });
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();
await expect(sender).toContainText('Sent to ComfyUI.');
@@ -1,195 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {seedConcept,CONCEPT_URL} from './concept-inventory.mjs';
// #530: measured geometry for three deviations from the concept, found in the maintainer's test of the cutover branch.
// D182 two-column card tops line up, D183 Reusable tags scrolls in its own window (D185 on touch and at 390px),
// D184 System uses the full content width.
const workspace='/prompt_manager/workspace';
const TAGS=`${workspace}#/tags`;
const main=page=>page.locator('#workspace-main');
const cards=page=>main(page).locator('.two-col > section.surface');
const tagWindow=page=>main(page).locator('.two-col .table-wrap');
const rows=page=>tagWindow(page).locator('tbody tr');
const loadMore=page=>main(page).getByRole('button',{name:'Load more tags',exact:true});
const boxes=locator=>locator.evaluateAll(nodes=>nodes.map(node=>{const {top,bottom,left,right,width}=node.getBoundingClientRect();return {top,bottom,left,right,width};}));
const seedTags=async(request,data={})=>expect(await request.post('/__test__/vocabulary/reset',{data})).toBeOK();
const nextFrame=page=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
test.beforeEach(async({page,browserName})=>{
await startCoverage(page,browserName);
page.layoutErrors=[];page.on('pageerror',error=>page.layoutErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.layoutErrors).toEqual([]);});
test('two-column cards start on the same line; the concept still has the 18px offset (D182)',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
await page.goto(`${workspace}#/system/overview`);
await expect(main(page).locator('.summary-metrics')).toBeVisible();
const [health,preferences]=await boxes(cards(page));
expect(preferences.top).toBe(health.top);
await seedTags(request);
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
const [tags,categories]=await boxes(cards(page));
expect(categories.top).toBe(tags.top);
// The deviation is real: the immutable concept keeps its offset.
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#tags`);
const concept=await boxes(page.locator('.two-col > section.surface'));
expect(concept).toHaveLength(2);
expect(Math.round(concept[1].top-concept[0].top)).toBe(18);
});
test('stacked cards keep one 18px gap at 390px (D182)',async({page,request})=>{
await page.setViewportSize({width:390,height:844});
await seedTags(request);
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
const [tags,categories]=await boxes(cards(page));
expect(Math.round(categories.top-tags.bottom)).toBe(18);
});
test('System sections use the full content width, as Inspect does (D184)',async({page,request})=>{
await page.setViewportSize({width:1920,height:1100});
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
for(const section of ['overview','appearance','sources','maintenance']) {
await page.goto(`${workspace}#/system/${section}`);
const layout=main(page).locator('.settings-layout');
await expect(layout).toBeVisible();
const [[box],[nav]]=await Promise.all([boxes(layout),boxes(main(page).locator(':scope > .section-nav'))]);
expect([section,box.left,box.width]).toEqual([section,nav.left,nav.width]);
}
await page.goto(`${workspace}#/system/overview`);
await expect(main(page).locator('.summary-metrics')).toBeVisible();
const [[metrics],[columns],[nav]]=await Promise.all([boxes(main(page).locator('.summary-metrics')),boxes(main(page).locator('.settings-layout > .two-col')),boxes(main(page).locator(':scope > .section-nav'))]);
expect(metrics.right).toBeCloseTo(nav.right,0);
expect(columns.right).toBeCloseTo(nav.right,0);
await page.goto(`${workspace}#/inspect`);
await expect(main(page).locator('section.surface').first()).toBeVisible();
const [inspect]=await boxes(main(page).locator('section.surface').first());
expect(inspect.width).toBeCloseTo(nav.width,0);
});
// Every System section and the Tags page: blocks stacked in one column are never closer than the concept's 18px surface
// gap. Between a `.two-col` and the surface after it the concept has no gap at all (D182).
const SECTIONS=['overview','appearance','sources','integrations','tagging','maintenance','backups','diagnostics'];
const stackGaps=host=>host.evaluate(node=>{
const blocks=[...node.children].filter(child=>child.matches('.surface,.two-col')&&child.getClientRects().length);
return blocks.slice(1).map((block,index)=>[blocks[index].className,block.className,Math.round(block.getBoundingClientRect().top-blocks[index].getBoundingClientRect().bottom)]);
});
test('every stacked card has one 18px gap, including after a two-column row (D182)',async({page,request})=>{
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
for(const section of SECTIONS) {
await page.goto('about:blank');await page.goto(`${workspace}#/system/${section}`);
const layout=main(page).locator('.settings-layout');
await expect(layout.locator(':scope > .surface, :scope > .two-col').first()).toBeVisible();
for(const gap of await stackGaps(layout)) expect([width,section,...gap]).toEqual([width,section,gap[0],gap[1],18]);
}
await seedTags(request);
await page.goto('about:blank');await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
for(const gap of await stackGaps(main(page))) expect([width,'tags',...gap]).toEqual([width,'tags',gap[0],gap[1],18]);
}
// The deviation is real: the immutable concept has no gap after its Backup & export columns.
await page.setViewportSize({width:1600,height:1100});
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#system`);
await page.locator('#main .section-nav').getByRole('button',{name:'Backup & export',exact:true}).click();
const policy=page.locator('#main .settings-layout > section.surface');await expect(policy).toBeVisible();
expect(await policy.evaluate(node=>Math.round(node.getBoundingClientRect().top-node.previousElementSibling.getBoundingClientRect().bottom))).toBe(0);
});
test('a long tag list scrolls in its own window and the page does not grow (D183)',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});
await seedTags(request,{extra_tags:211});
await page.goto(TAGS);
await expect(rows(page)).toHaveCount(100);
const size=()=>tagWindow(page).evaluate(node=>({scrollHeight:node.scrollHeight,clientHeight:node.clientHeight,scrollTop:node.scrollTop}));
const before=await size();
expect(before.scrollHeight).toBeGreaterThan(before.clientHeight);
expect(before.clientHeight).toBeGreaterThanOrEqual(280);
const [tagCard]=await boxes(cards(page));
expect(tagCard.bottom).toBeLessThanOrEqual(1100);
await expect(loadMore(page)).toBeInViewport();
// The header stays at the top of the window while the rows move under it.
await tagWindow(page).evaluate(node=>{node.scrollTop=600;});await nextFrame(page);
const [[head],[frame]]=await Promise.all([boxes(tagWindow(page).locator('thead th').first()),boxes(tagWindow(page))]);
expect(Math.round(head.top)).toBe(Math.round(frame.top));
// Reaching the end of the window appends one page; the page itself did not scroll.
await tagWindow(page).evaluate(node=>{node.scrollTop=node.scrollHeight;});
await expect(rows(page)).toHaveCount(200);
expect(await page.evaluate(()=>window.scrollY)).toBe(0);
// One arrival is one page: the appended rows push the end away, so pages do not chain while more remain.
await nextFrame(page);await nextFrame(page);
await expect(rows(page)).toHaveCount(200);
await expect(loadMore(page)).toBeVisible();
// Load more works from below the window, with the window not at its end.
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
await loadMore(page).click();
await expect(rows(page)).toHaveCount(211+19);
await expect(loadMore(page)).toHaveCount(0);
// A new sort is a new list: the window returns to its top.
await tagWindow(page).evaluate(node=>{node.scrollTop=800;});await nextFrame(page);
expect((await size()).scrollTop).toBe(800);
await main(page).getByRole('combobox',{name:'Sort tags',exact:true}).selectOption({index:1});
await expect.poll(async()=>(await size()).scrollTop).toBe(0);
});
test('a short tag list has no inner scrolling (D183)',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});
await seedTags(request);
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
await page.setViewportSize({width:1600,height:2000});await nextFrame(page);
const {scrollHeight,clientHeight}=await tagWindow(page).evaluate(node=>({scrollHeight:node.scrollHeight,clientHeight:node.clientHeight}));
expect(scrollHeight).toBe(clientHeight);
});
test('at 390px the window fits the screen, actions stay reachable and the page still scrolls (D185)',async({page,request})=>{
await page.setViewportSize({width:390,height:844});
await seedTags(request,{extra_tags:211});
await page.goto(TAGS);
await expect(rows(page)).toHaveCount(100);
const [frame]=await boxes(tagWindow(page));
expect(frame.bottom-frame.top).toBeLessThan(844);
expect(frame.bottom-frame.top).toBeGreaterThanOrEqual(280);
expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
// The last row's actions can be scrolled to inside the window and fit its width.
const last=rows(page).last().getByRole('button',{name:'Rename / merge',exact:true});
await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
const [button]=await boxes(last);const [after]=await boxes(tagWindow(page));
expect(button.right).toBeLessThanOrEqual(after.right);
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
await expect(loadMore(page)).toBeAttached();
await loadMore(page).scrollIntoViewIfNeeded();await expect(loadMore(page)).toBeInViewport();
});
test('a swipe moves the window inside it and the page outside it (D185)',async({page,request,browserName},testInfo)=>{
test.skip(browserName!=='chromium'||testInfo.project.name!=='touch','needs a touch-emulating Chromium');
await page.setViewportSize({width:390,height:844});
await seedTags(request,{extra_tags:211});
await page.goto(TAGS);
await expect(rows(page)).toHaveCount(100);
await tagWindow(page).evaluate(node=>node.scrollIntoView({block:'start'}));await nextFrame(page);
// A real touch sequence: the browser decides what scrolls.
const swipe=async(x,y,distance)=>{
const client=await page.context().newCDPSession(page);
const touch=(type,at)=>client.send('Input.dispatchTouchEvent',{type,touchPoints:type==='touchEnd'?[]:[{x,y:at}]});
await touch('touchStart',y);
for(let step=1;step<=10;step++) {await touch('touchMove',y+distance*step/10);await nextFrame(page);}
await touch('touchEnd');
await client.detach();await nextFrame(page);
};
const [frame]=await boxes(tagWindow(page));
const y=Math.round(frame.top+(frame.bottom-frame.top)/2);
const pageTop=await page.evaluate(()=>window.scrollY);
await swipe(195,y,-150);
await expect.poll(()=>tagWindow(page).evaluate(node=>node.scrollTop)).toBeGreaterThan(50);
expect(await page.evaluate(()=>window.scrollY)).toBe(pageTop);
// From the top of the window a downward swipe chains to the page, so a phone is never trapped in the list.
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
await swipe(195,y,200);
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBeLessThan(pageTop);
});
+6 -399
View File
@@ -13,8 +13,6 @@ 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':'';
@@ -50,8 +48,7 @@ test('Delete… reviews the selection, deletes the unpaired, archives the paired
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']);
// #512: Cat portrait is also another prompt's negative in the fixture, so the review says so.
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair',"1 prompt used as another prompt's negative: hidden, restorable",
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/);
@@ -82,26 +79,22 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
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 (1 used as another prompt's negative): hidden, restorable");
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 expect(showArchived(page)).toHaveAttribute('aria-pressed','false');
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(showArchived(page)).toHaveAttribute('aria-pressed','true');
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();
// #492: the archived shelf offers Delete… after Restore.
await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Delete…','Clear']);
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 showArchived(page).click();
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]);
@@ -111,247 +104,6 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
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);
});
// #492: Delete… on the archived shelf deletes archived prompts permanently, with their own saved pairs; no choice.
async function archiveByApi(request,ids) {
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
const preview=await(await request.post(PREVIEW,{data:{prompt_ids:ids,expected_epoch:epoch}})).json();
expect(preview.archive).toEqual(ids);
await expect(await request.post(DELETE,{data:{archive_ids:ids,delete_ids:[],expected_epoch:epoch,backup:'on'}})).toBeOK();
return epoch;
}
test('Delete… on the archived shelf deletes an archived prompt and its saved pair permanently (#492)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const epoch=await archiveByApi(request,[dual.id]);
const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE);
await page.goto(route);await showArchived(page).click();await expect(card(page,dual.id)).toHaveCount(1);
await box(page,dual.id).check();
await openReview(page);
await expect(review(page)).toHaveAccessibleName('Delete archived prompts');
await expect(choice(page)).toHaveCount(0);
// A permanent delete only: the review opens on Cancel, and its copy says it cannot be undone and takes the pairs.
await expect(review(page).getByRole('button',{name:'Cancel',exact:true})).toBeFocused();
await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/);
await expect(review(page).locator('p.small').nth(1)).toContainText('the only way back is to restore the safety backup taken first');
await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair');
await expect(row(page,'Saved pairs removed')).toHaveText(/^1 saved pair/);
await expect(row(page,'Backup')).toHaveText('Create and verify before deleting');
expect(previews).toEqual([{prompt_ids:[dual.id],expected_epoch:epoch,shelf:'archived'}]);
// Cancel writes nothing and returns focus to Delete….
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})).toBeFocused();
await openReview(page);
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: /);
expect(writes).toHaveLength(1);
expect(writes[0]).toMatchObject({archive_ids:[dual.id],delete_ids:[],delete_paired_ids:[dual.id],shelf:'archived'});
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
await backToLibrary(page).click();
await expect(showArchived(page)).toHaveText('Show archived prompts (0)');
expect(byText(await items(request),'Dual role portrait')).toBeUndefined();
expect(await items(request,'archived')).toEqual([]);
});
test('an archived prompt restored since the shelf was read is kept, in a close-only review (#492)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await archiveByApi(request,[dual.id]);
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
await page.goto(route);await showArchived(page).click();await expect(card(page,dual.id)).toHaveCount(1);
await box(page,dual.id).check();
// Restored elsewhere after the shelf was read: the review counts it and deletes nothing.
await expect(await request.post('/prompt_manager/library/restore',{data:{prompt_ids:[dual.id],expected_epoch:epoch}})).toBeOK();
await openReview(page);
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted.');
await expect(row(page,'No longer archived \\(kept\\)')).toHaveText('1');
await expect(review(page).getByRole('button',{name:/^Delete \d/})).toHaveCount(0);
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
});
// #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();
// #512: the page's review names its one prompt.
await expect(review(page).locator('p.small').last()).toHaveText('This prompt cannot 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;
@@ -461,148 +213,3 @@ test('a failed review keeps the selection, and a selection nothing can change op
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
expect(byText(await items(request),'Library extra 0000')).toBeDefined();
});
// #512 (live test of #505/#507/#509): the prompt page's "⋯" More menu (D163), a gone prompt's page, single-prompt titles.
const more=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'More actions for this prompt',exact:true});
const moreMenu=page=>page.getByRole('menu',{name:'More actions for this prompt'});
test('the prompt page\'s More menu opens Delete prompt…, by mouse and keyboard, and closes on Escape or outside (#512 D163)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
const heading=page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button');
await expect(heading).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
await expect(more(page)).toHaveAttribute('aria-haspopup','menu');await expect(more(page)).toHaveAttribute('aria-expanded','false');
await expect(moreMenu(page)).toBeHidden();
// Keyboard: Enter opens on the first item; Escape closes and returns focus.
await more(page).focus();await page.keyboard.press('Enter');
await expect(more(page)).toHaveAttribute('aria-expanded','true');
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
await expect(item).toBeFocused();await expect(item).toHaveClass(/\bdanger\b/);
await page.keyboard.press('ArrowDown');await expect(item).toBeFocused();
await page.keyboard.press('Escape');
await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toBeFocused();await expect(more(page)).toHaveAttribute('aria-expanded','false');
// ArrowUp opens on the last item; ArrowUp, Home and End move within it (one item here); Tab closes it.
await page.keyboard.press('ArrowUp');await expect(item).toBeFocused();
for(const key of ['ArrowUp','Home','End']) {await page.keyboard.press(key);await expect(item).toBeFocused();}
await page.keyboard.press('Tab');await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toHaveAttribute('aria-expanded','false');
// Space opens too; an outside press closes it.
await more(page).focus();await page.keyboard.press(' ');await expect(item).toBeFocused();
await page.locator('#workspace-main h1').click();await expect(moreMenu(page)).toBeHidden();
// Choosing it opens the page's review, titled for this prompt; Cancel returns focus to ⋯.
await more(page).click();await item.click();
await expect(moreMenu(page)).toBeHidden();
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await expect(review(page).locator('#modal-title')).toHaveText('Delete this prompt');
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(review(page)).toHaveCount(0);await expect(more(page)).toBeFocused();
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
});
test('the More menu fits a 390px viewport and works by touch (#512 D163)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await page.setViewportSize({width:390,height:844});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await more(page).click();
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
await expect(item).toBeVisible();
const box=await moreMenu(page).boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390);
await item.click();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 1 prompt.');
await expect(page.locator('.library-archived-notice')).toBeVisible();
});
test('a deleted prompt\'s page says it no longer exists, with the way back and no Retry (#512)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await deleteFromPage(page,dual.id);
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
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(page).toHaveURL(/#\/library$/);
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.');
await expect(page.getByRole('button',{name:'Retry prompt history'})).toHaveCount(0);
await expect(editMetadata(page)).toBeDisabled();await expect(more(page)).toBeDisabled();
await page.getByRole('button',{name:'Open the Prompt library',exact:true}).click();
await expect(page).toHaveURL(/#\/library$/);
});
// #524 (live test of #522): a long title clamps beside the heading actions, which stay on one row at desktop widths.
const LONG_TITLE='masterpiece, high quality, very_awa, newest, absurdres, highres, depth_of_field, detailed eyes, realistic eyes, fake eyelashes, black eyes, white_nails, 1girl, solo, black bob_cut, upper body';
async function openLongTitle(page,request,width) {
const dual=byText(await items(request),'Dual role portrait');
await page.route(new RegExp(`/prompt_manager/library/${dual.id}\\?`),async route=>{
const response=await route.fetch(),json=await response.json();
await route.fulfill({response,json:{...json,positive:{...json.positive,title:LONG_TITLE}}});
});
await page.setViewportSize({width,height:900});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await expect(page.locator('#workspace-main h1')).toHaveText(LONG_TITLE);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth)).toBe(true);
return page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button');
}
for(const width of [1440,951]) test(`a long title clamps and the heading actions stay on one row at ${width}px (#524)`,async({page,request})=>{
const actions=await openLongTitle(page,request,width);
await expect(actions).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
const tops=await actions.evaluateAll(nodes=>nodes.map(node=>Math.round(node.getBoundingClientRect().top)));
expect(new Set(tops).size).toBe(1);
const h1=page.locator('#workspace-main h1');
const shape=await h1.evaluate(node=>({scroll:node.scrollHeight,client:node.clientHeight,line:parseFloat(getComputedStyle(node).lineHeight),title:node.title}));
expect(shape.scroll).toBeGreaterThan(shape.client);
expect(shape.client).toBeLessThanOrEqual(Math.ceil(shape.line*2));
// The clamp hides pixels only: the whole title stays the text and shows on hover.
expect(shape.title).toBe(LONG_TITLE);
// The buttons do not shrink: no label is cut or broken onto a second line.
const broken=await actions.evaluateAll(nodes=>nodes.filter(node=>node.scrollWidth>node.clientWidth||node.getBoundingClientRect().height>44).length);
expect(broken).toBe(0);
const [h1Box,firstBox]=[await h1.boundingBox(),await actions.first().boundingBox()];
expect(h1Box.x+h1Box.width).toBeLessThanOrEqual(firstBox.x);
});
test('a long title at 390px keeps the concept\'s stacked heading, not clamped, inside the viewport (#524)',async({page,request})=>{
const actions=await openLongTitle(page,request,390);
const h1=await page.locator('#workspace-main h1').evaluate(node=>({scroll:node.scrollHeight,client:node.clientHeight}));
expect(h1.scroll).toBeLessThanOrEqual(h1.client);
const right=await actions.evaluateAll(nodes=>Math.max(...nodes.map(node=>node.getBoundingClientRect().right)));
expect(right).toBeLessThanOrEqual(390);
});
test('a deleted prompt\'s page has no history subtitle, and a page that loads shows it (#524)',async({page,request})=>{
const list=await items(request),dual=byText(list,'Dual role portrait'),dog=byText(list,'Dog landscape');
const description=page.locator('#workspace-main > .heading p.muted');
await deleteFromPage(page,dual.id);
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(page).toHaveURL(/#\/library$/);
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.');
await expect(description).toBeHidden();await expect(description).toHaveText('');
await page.goto(`/prompt_manager/workspace#/library/${dog.id}`);
await expect(description).toHaveText('Prompt history · one positive, one shared gallery');await expect(description).toBeVisible();
});
test('the archived shelf\'s heading count and its list agree under a remembered search (#512 item 5)',async({page,request})=>{
const all=await items(request);
const dual=byText(all,'Dual role portrait'),dog=byText(all,'Dog landscape');
await archiveByApi(request,[dual.id]);
await page.goto(route);
// A search that matches a shelf prompt but not the archived one.
await page.getByRole('searchbox').fill('landscape');
await expect(card(page,dog.id)).toHaveCount(1);await expect(card(page,dual.id)).toHaveCount(0);
const matching=(await(await request.get('/prompt_manager/library?limit=100&shelf=archived&text=landscape')).json()).items.length;
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
// Leave and come back: the remembered search is shown and still counts the same.
await page.goto('/prompt_manager/workspace#/overview');await page.goto(route);
await expect(page.getByRole('searchbox')).toHaveValue('landscape');
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
await showArchived(page).click();
await expect(backToLibrary(page)).toBeVisible();
if(matching) await expect(cards(page)).toHaveCount(matching);
else await expect(page.locator('.library-status')).toHaveText('No archived prompts match.');
await page.getByRole('searchbox').fill('');
await expect(card(page,dual.id)).toHaveCount(1);
await backToLibrary(page).click();
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
});
+29 -42
View File
@@ -8,7 +8,7 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
// 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']};
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','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.';
@@ -27,15 +27,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 +52,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);
@@ -75,14 +73,9 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
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})=>{
@@ -151,13 +144,13 @@ test('editor modes persist through reload with selected saved negative',async({p
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 +163,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 +178,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 +192,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 +209,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 +237,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 +319,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 +347,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);
});
@@ -500,10 +493,9 @@ test('a restore seen finishing keeps New prompt open and reloads the Library aft
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").
// D152 (#443): Show archived prompts (N) comes first, a plain toggle.
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/);
await expect(actions.nth(0)).toHaveAttribute('aria-pressed','false');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
// 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/);
@@ -540,9 +532,8 @@ test('new from Images cancel preserves browse',async({page})=>{
test('history heading follows the concept and sends the selected saved pair',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const actions=headingActions(page).locator('button:not([role="menuitem"])');
// D163 (#512): the More menu button follows Send to ComfyUI.
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
const actions=headingActions(page).locator('button');
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
@@ -644,20 +635,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);
@@ -674,10 +661,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();
@@ -744,7 +731,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);
@@ -887,7 +874,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);
+3 -3
View File
@@ -176,7 +176,7 @@ test('failed library reads retry while late search responses cannot overwrite ne
// 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'&&url.searchParams.get('limit')!=='1')sorts.push(url.searchParams.get('sort'));});// limit=1 is a sidebar count read, not the shelf
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);
@@ -364,7 +364,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Library destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Library destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(sender).toContainText(chosenNegative||'(empty)');
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
@@ -716,7 +716,7 @@ test('long prompt is clamped to two lines while copy sends the full text',async(
await card.locator('.card-footer .row-actions > button.send').click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Card ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#positive-1 Card destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Card destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.cardApplied)).toEqual([{positive:longPrompt}]);
@@ -1,153 +0,0 @@
// The one-time older-images notice in the workspace shell (#527, D166). Its dismissal is the legacy admin page's key,
// so dismissing it in any place dismisses it everywhere.
import { test, expect } from './fixtures.mjs';
const WORKSPACE = '/prompt_manager/workspace';
const ADMIN = '/prompt_manager/admin';
const NOTICE_KEY = 'pm_update_notice_dismissed';
const shellNotice = page => page.locator('#older-images-notice > .notice');
const sourcesNotice = page => page.locator('#workspace-main .settings-layout > .notice');
const dialog = page => page.locator('dialog#modal[open]');
const adminModal = page => page.locator('#updateNoticeModal');
const stored = page => page.evaluate(key => localStorage.getItem(key), NOTICE_KEY);
async function openRoute(page, route) {
await page.goto(`${WORKSPACE}#/${route}`);
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
}
// Every spec on this worker's server shares its job list.
test.beforeEach(async ({ request }) => {
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
});
test.describe('with the notice not dismissed', () => {
test.use({ olderImagesNotice: true });
for (const route of ['library', 'images', 'tags', 'activity', 'system/overview']) {
test(`shows above the ${route} page with Later and Review scan`, async ({ page }) => {
await openRoute(page, route);
await expect(shellNotice(page).locator('h2')).toHaveText('Bring older images into your prompt history');
await expect(shellNotice(page).locator('.row-actions > button')).toHaveText(['Later', 'Review scan']);
await expect(page.locator('#older-images-notice')).toBeVisible();
});
}
test('Later dismisses it for good without a write or a scan', async ({ page, request }) => {
const writes = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
await openRoute(page, 'library');
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await expect(shellNotice(page)).toHaveCount(0);
await expect(page.locator('#older-images-notice')).toBeHidden();
await expect(page.locator('#workspace-main')).toBeFocused();
expect(await stored(page)).toBe('3.2.0');
await expect(dialog(page)).toHaveCount(0);
await page.reload();
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
await expect(shellNotice(page)).toHaveCount(0);
expect(writes).toEqual([]);
expect((await (await request.get('/prompt_manager/jobs')).json()).jobs).toEqual([]);
});
test('Review scan dismisses it and opens the review of every source', async ({ page }) => {
await openRoute(page, 'library');
await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
await expect(shellNotice(page)).toHaveCount(0);
expect(await stored(page)).toBe('3.2.0');
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('#workspace-main')).toBeFocused();
});
test('Review scan keeps the notice and says so when the sources cannot be read', async ({ page }) => {
await openRoute(page, 'library');
await page.route('**/prompt_manager/settings', route => route.fulfill({ status: 500, json: { success: false } }));
await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(page.locator('#workspace-toast')).toHaveText('Could not read the image sources. Try again.');
await expect(shellNotice(page)).toHaveCount(1);
await expect(shellNotice(page).getByRole('button', { name: 'Review scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
await expect(dialog(page)).toHaveCount(0);
expect(await stored(page)).toBeNull();
});
test('System > Sources shows one notice, its own, and dismissing it there clears the shell', async ({ page }) => {
await openRoute(page, 'system/overview');
await expect(shellNotice(page)).toHaveCount(1);
await page.goto(`${WORKSPACE}#/system/sources`);
await expect(page.locator('#workspace-main .settings-layout > .notice:visible')).toHaveCount(1);
await expect(shellNotice(page)).toHaveCount(0);
await sourcesNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await expect(sourcesNotice(page)).toHaveCount(0);
await page.goto(`${WORKSPACE}#/library`);
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
await expect(shellNotice(page)).toHaveCount(0);
});
test('dismissing in the shell clears the copy System has already mounted', async ({ page }) => {
await openRoute(page, 'system/sources');
await expect(sourcesNotice(page)).toHaveCount(1);
await page.goto(`${WORKSPACE}#/system/overview`);
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await page.goto(`${WORKSPACE}#/system/sources`);
await expect(page.locator('#workspace-main #source-list')).toBeVisible();
await expect(sourcesNotice(page)).toHaveCount(0);
});
test('dismissing in another tab clears it here', async ({ page, context }) => {
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(1);
const other = await context.newPage();
await openRoute(other, 'images');
await shellNotice(other).getByRole('button', { name: 'Later', exact: true }).click();
await expect(shellNotice(page)).toHaveCount(0);
await other.close();
});
test('the admin modal still works: Later without the checkbox keeps the notice everywhere', async ({ page }) => {
await page.goto(ADMIN);
await expect(adminModal(page)).toBeVisible();
await page.locator('#updateNoticeDismiss').click();
await expect(adminModal(page)).toBeHidden();
expect(await stored(page)).toBeNull();
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(1);
});
test('dismissing on the admin page dismisses it in the workspace', async ({ page }) => {
await page.goto(ADMIN);
await page.locator('#dismissUpdateNotice').check();
await page.locator('#updateNoticeDismiss').click();
await expect(adminModal(page)).toBeHidden();
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(0);
});
test('dismissing in the workspace dismisses it on the admin page', async ({ page }) => {
await openRoute(page, 'library');
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
await page.goto(ADMIN);
await page.waitForLoadState('load');
await expect(adminModal(page)).toBeHidden();
});
test('the admin Scan still dismisses it and opens the scan modal', async ({ page }) => {
await page.goto(ADMIN);
await page.locator('#updateNoticeScan').click();
await expect(adminModal(page)).toBeHidden();
expect(await stored(page)).toBe('3.2.0');
});
});
test('specs start with the notice dismissed, on every page', async ({ page, browser, baseURL }) => {
await openRoute(page, 'library');
await expect(shellNotice(page)).toHaveCount(0);
const context = await browser.newContext({ baseURL });
const other = await context.newPage();
await other.goto(ADMIN);
await other.waitForLoadState('load');
await expect(adminModal(other)).toBeHidden();
await context.close();
});
+23 -120
View File
@@ -15,11 +15,8 @@ const cards = page => main(page).locator('.review-queue article.media-card');
const toast = page => page.locator('#workspace-toast');
const NOTICE = 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.';
const EMPTY = 'Ambiguous images are added when a metadata scan or a new generation finds more than one sampler path.';
const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur'];
const leaveUnresolved = page => panel(page).getByRole('button', { name: 'Leave unresolved', exact: true });
const linkButton = page => panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true });
const linkDialog = page => page.getByRole('dialog', { name: 'Link to a prompt', exact: true });
const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur',
'Leave unresolved'];
async function seed(request, items = 3, special = {}) {
const response = await request.post('/__test__/review/reset', { data: { items, ...special } });
@@ -47,7 +44,7 @@ const warning = page => panel(page).locator('.warning-note');
/** The page with its first item's sampler paths loaded. */
async function open(page) {
await page.goto(review);
await expect(leaveUnresolved(page)).not.toHaveAttribute('aria-disabled', 'true');
await expect(context(page)).toBeEnabled();
}
test.beforeEach(async ({ page, browserName }) => {
@@ -83,6 +80,8 @@ test('queue shows persisted items and the nav count', async ({ page, request })
await expect(panel(page).locator('.prompt-section.negative .prompt-text')).toHaveText('Soft negative, blur');
await context(page).selectOption({ label: OPTIONS[1] });
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Harbor lights at night');
await context(page).selectOption({ label: 'Leave unresolved' });
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Multiple possible prompts. Source could not be determined.');
// Info selects another item.
await cards(page).nth(1).getByRole('button', { name: 'Inspect review_0001.png', exact: true }).click();
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
@@ -91,14 +90,6 @@ test('queue shows persisted items and the nav count', async ({ page, request })
// The card's image opens the shared viewer.
await cards(page).nth(1).getByRole('button', { name: 'Open review_0001.png', exact: true }).click();
await expect(page.locator('#viewer-dialog')).toBeVisible();
// #532: the arrows move through the queue from the first moment, with focus still on ✕.
await expect(page.locator('#viewer-close')).toBeFocused();
const before = await page.locator('#viewer-dialog .media-position').textContent();
await page.keyboard.press('ArrowRight');
await expect(page.locator('#viewer-dialog .media-position')).not.toHaveText(before);
// This viewer has no Delete…, so the Delete key does nothing.
await page.keyboard.press('Delete');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
// Open activity.
@@ -114,9 +105,7 @@ test('apply candidate links the pair and the item leaves the queue and the count
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
expect((await decided).status()).toBe(200);
await expect(toast(page)).toHaveText('Association accepted.');
// #536: the page stays and the next item opens.
await expect(page).toHaveURL(/#\/review$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
await expect(page).toHaveURL(/#\/images$/);
await expect(reviewRow(page)).toHaveText('Needs review1');
expect(await pending(request)).toBe(1);
// The first sampler path's pair is linked to that file, and only to it.
@@ -146,20 +135,20 @@ test('conflict leaves links unchanged and explains', async ({ page, request }) =
expect(await pending(request)).toBe(1);
await expect(reviewRow(page)).toHaveText('Needs review1');
// The file can still be left unresolved, which links nothing either.
await leaveUnresolved(page).click();
await context(page).selectOption({ label: 'Leave unresolved' });
await apply(page).click();
await expect(toast(page)).toHaveText('Left unresolved.');
expect(await links(request)).toEqual(before);
expect(await pending(request)).toBe(0);
});
test('a limited item can be linked or left unresolved; an unavailable file only left unresolved', async ({ page, request }) => {
test('a limited item and an unavailable file can only be left unresolved', async ({ page, request }) => {
await seed(request, 0, { limited: true, unavailable: true });
await open(page);
await expect(cards(page)).toHaveCount(2);
await expect(panel(page).locator('p.small')).toHaveText('limited.png');
await expect(context(page)).toBeHidden();
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. Link it to a prompt or leave it unresolved.');
await expect(linkButton(page)).toHaveClass(/ca-primary/);
await expect(context(page).locator('option')).toHaveText(['Leave unresolved']);
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. You can leave it unresolved.');
// The unavailable file: no current root lists it, so its media and Tag are inert.
const unavailable = cards(page).nth(1);
await expect(unavailable.locator('.image-open')).toHaveAttribute('aria-disabled', 'true');
@@ -177,8 +166,7 @@ test('a limited item can be linked or left unresolved; an unavailable file only
await expect(panel(page).getByRole('button', { name: 'Tag image', exact: true })).toHaveAttribute('aria-disabled', 'true');
const observes = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/observe')) observes.push(item.url()); });
await expect(linkButton(page)).toHaveAttribute('aria-disabled', 'true');
await leaveUnresolved(page).click();
await apply(page).click();
await expect(toast(page)).toHaveText('Left unresolved.');
expect(observes).toEqual([]);
expect(await pending(request)).toBe(1);
@@ -187,20 +175,20 @@ test('a limited item can be linked or left unresolved; an unavailable file only
test('leave unresolved removes the item from pending', async ({ page, request }) => {
await seed(request, 2);
await open(page);
await leaveUnresolved(page).click();
await context(page).selectOption({ label: 'Leave unresolved' });
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
await expect(toast(page)).toHaveText('Left unresolved.');
await expect(page).toHaveURL(/#\/review$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
await expect(page).toHaveURL(/#\/images$/);
expect(await pending(request)).toBe(1);
});
test('leave for later writes nothing and opens the next item', async ({ page, request }) => {
test('leave for later writes nothing and goes to Images', async ({ page, request }) => {
await seed(request, 2);
await open(page);
const writes = [];
page.on('request', item => { if (item.method() !== 'GET') writes.push(item.url()); });
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).click();
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
await expect(page).toHaveURL(/#\/images$/);
expect(writes).toEqual([]);
expect(await pending(request)).toBe(2);
});
@@ -233,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);
@@ -344,7 +305,7 @@ test('keyboard focus labels and touch targets', async ({ page, request }) => {
const order = await panel(page).evaluate(node => [...node.querySelectorAll('button, select')]
.filter(control => control.offsetParent !== null).map(control => control.getAttribute('aria-label') || control.textContent.trim()));
expect(order[0]).toBe('Tag image');
expect(order.slice(-3)).toEqual(['Leave for later', 'Leave unresolved', 'Apply association']);
expect(order.slice(-2)).toEqual(['Leave for later', 'Apply association']);
// Targets: the panel's controls are 40px on a coarse pointer (the concept's coarse buttons) and 24px otherwise (the
// WCAG 2.2 minimum); the card's buttons keep the concept's compact size, never under 24px (its ≤700px rule).
const coarse = await page.evaluate(() => matchMedia('(pointer: coarse)').matches);
@@ -362,10 +323,10 @@ test('keyboard focus labels and touch targets', async ({ page, request }) => {
expect(box.height, label).toBeGreaterThanOrEqual(minimum);
expect(box.width, label).toBeGreaterThanOrEqual(24);
}
// Enter on Leave for later stays on the page; the only item stays open.
// Enter on Leave for later returns to Images.
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/#\/review$/);
await expect(page).toHaveURL(/#\/images$/);
});
test('an open page shows items a scan adds and keeps the open choice', async ({ page, request }) => {
@@ -403,8 +364,9 @@ test('a decision in another tab leaves the open page and opens the next image',
await expect.poll(async () => (await streams(request)).streams).toBe(1);
const other = await browserContext.newPage();
await open(other);
await leaveUnresolved(other).click();
await expect(toast(other)).toHaveText('Left unresolved.');
await context(other).selectOption({ label: 'Leave unresolved' });
await apply(other).click();
await expect(other).toHaveURL(/#\/images$/);
await other.close();
await expect(cards(page)).toHaveCount(1);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
@@ -624,62 +586,3 @@ test('teardown releases captures listeners and requests', async ({ page, request
await closedStreamsDropped(request);
await expect.poll(() => inFlight.size).toBe(0);
});
test('#536: nothing selectable says why and links through the picker, then the next item opens', async ({ page, request }) => {
await seed(request, 1, { unreadable: true });
await open(page);
await cards(page).nth(1).getByRole('button', { name: 'Inspect unreadable.png', exact: true }).click();
await expect(panel(page)).toContainText("None of this file's 2 sampler paths could be traced to a complete prompt");
await expect(panel(page)).toContainText('Unsupported latent ancestry (2 paths)');
await expect(context(page)).toBeHidden();
await expect(panel(page).getByRole('button', { name: 'Apply association', exact: true })).toBeHidden();
await expect(panel(page)).not.toContainText('Multiple possible prompts');
await expect(linkButton(page)).toHaveClass(/ca-primary/);
await linkButton(page).click();
const dialog = linkDialog(page);
await expect(dialog).toContainText("This records your choice; the file's metadata does not confirm it.");
const submit = dialog.getByRole('button', { name: 'Link image', exact: true });
await expect(submit).toHaveAttribute('aria-disabled', 'true');
await dialog.getByRole('searchbox').fill('harbor');
const harbor = dialog.getByRole('button', { name: 'Quiet harbor', exact: true });
await expect(harbor).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Golden dunes at dusk', exact: true })).toHaveCount(0);
await harbor.click();
// Several recipes: the reviewer must choose.
const negative = dialog.getByRole('combobox', { name: 'Negative prompt', exact: true });
await expect(negative.locator('option')).toHaveText(['Choose a negative…', 'washed out', 'grainy', 'No negative']);
await expect(submit).toHaveAttribute('aria-disabled', 'true');
await expect(submit).toHaveCSS('opacity', '0.55');
await expect(harbor).toHaveAttribute('aria-pressed', 'true');
await negative.selectOption({ label: 'grainy' });
const linked = page.waitForResponse(item => /\/link$/.test(item.url()));
await submit.click();
expect((await linked).status()).toBe(200);
await expect(toast(page)).toHaveText('Linked to Quiet harbor.');
await expect(dialog).toHaveCount(0);
await expect(page).toHaveURL(/#\/review$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0000.png');
expect(await pending(request)).toBe(1);
const link = (await links(request)).find(row => row.filename === 'unreadable.png');
expect([link.positive, link.negative]).toEqual(['Quiet harbor', 'grainy']);
});
test('#536: a refused link stays in the dialog with the server\'s sentence', async ({ page, request }) => {
await seed(request, 0, { unreadable: true });
await open(page);
await linkButton(page).click();
const dialog = linkDialog(page);
await dialog.getByRole('button', { name: 'Golden dunes at dusk', exact: true }).click();
// One recipe: preselected and visible.
const negative = dialog.getByRole('combobox', { name: 'Negative prompt', exact: true });
await expect(negative).toHaveValue(/^[0-9]+$/);
await page.route(/\/link$/, route => route.fulfill({ status: 409, contentType: 'application/json',
body: JSON.stringify({ success: false, code: 'association-conflict', error: 'This image is already linked.' }) }));
await dialog.getByRole('button', { name: 'Link image', exact: true }).click();
await expect(dialog.locator('.warning-note')).toHaveText('This image is already linked.');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await pending(request)).toBe(1);
expect(await links(request)).toEqual([]);
});
@@ -1,322 +0,0 @@
// #531 (D186-D189): the selection bars stay in view, a Back to top chip follows the page, and the side panels stay with
// you. Beyond the concept; the concept itself is unchanged.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { startAndAwaitJob } from './job-end.mjs';
const IMAGES = '/prompt_manager/workspace#/images';
const LIBRARY = '/prompt_manager/workspace#/library';
const REVIEW = '/prompt_manager/workspace#/review';
const PHONE = { width: 390, height: 844 };
const imageCards = page => page.locator('#image-grid article[data-image-key]');
const promptCards = page => page.locator('article[data-prompt-id]');
const imageBar = page => page.locator('#bulkbar > .bulkbar');
const libraryBar = page => page.locator('#library-bulkbar > .bulkbar');
const chip = page => page.getByRole('button', { name: 'Back to top', exact: true });
const toast = page => page.locator('#workspace-toast');
const reviewPanel = page => page.getByRole('region', { name: 'Resolve the association', exact: true });
const reviewCards = page => page.locator('.review-queue article.media-card');
const box = locator => locator.evaluate(node => {
const { top, bottom, left, right } = node.getBoundingClientRect();
return { top, bottom, left, right };
});
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
/** Scroll the window to `y` and wait for the frame that applied it. */
async function scrollToY(page, y) {
await page.evaluate(target => new Promise(resolve => {
scrollTo({ top: target, behavior: 'instant' });
requestAnimationFrame(() => requestAnimationFrame(resolve));
}), y);
}
/** The first card wholly below `below` px, by its index in the list. */
async function cardBelow(cards, below) {
const count = await cards.count();
for (let index = 0; index < count; index++) {
const card = cards.nth(index);
if ((await box(card)).top <= below) continue;
// By its key, not its position: the mounted window moves as the page does.
const key = await card.evaluate(node => ['imageKey', 'promptId', 'reviewId'].map(name => [name, node.dataset[name]]).find(([, value]) => value));
const attribute = key[0].replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
return cards.page().locator(`article[data-${attribute}=${JSON.stringify(key[1])}]`);
}
throw new Error(`no card below ${below}`);
}
async function openImages(page, request, extra = 100) {
await expect(await request.post('/__test__/reset', { data: { extra_images: extra } })).toBeOK();
await page.goto(IMAGES);
await expect(imageCards(page).first()).toBeVisible();
}
async function openLibrary(page, request, extra = 40) {
await expect(await request.post('/__test__/reset', { data: { native_library: true, extra_prompts: extra } })).toBeOK();
await page.goto(LIBRARY);
await expect(promptCards(page).first()).toBeVisible();
}
async function openReview(page, request, items = 30) {
await expect(await request.post('/__test__/review/reset', { data: { items } })).toBeOK();
await page.goto(REVIEW);
await expect(reviewPanel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
}
const inViewport = (rect, page) => rect.top >= 0 && rect.bottom <= page.viewportSize().height;
test.beforeEach(async ({ page, browserName }) => { await startCoverage(page, browserName); });
test.afterEach(async ({ page, browserName }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
for (const viewport of [null, PHONE]) {
const label = viewport ? ' at 390px' : '';
test(`Images: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
if (viewport) await page.setViewportSize(viewport);
await openImages(page, request);
await scrollToY(page, 1500);
const card = await cardBelow(imageCards(page), 300);
const before = (await box(card)).top;
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page).locator('strong')).toHaveText('1 selected');
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
// Keep scrolling and selecting: the bar stays; Clear returns the card to its place too.
await scrollToY(page, 1900);
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
const later = await cardBelow(imageCards(page), 300);
await later.locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page).locator('strong')).toHaveText('2 selected');
const held = (await box(later)).top;
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await expect(imageBar(page)).toHaveCount(0);
expect(Math.abs((await box(later)).top - held)).toBeLessThanOrEqual(1);
});
test(`Library: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
if (viewport) await page.setViewportSize(viewport);
await openLibrary(page, request);
await scrollToY(page, 2000);
await expect(promptCards(page).first()).toBeAttached();
const card = await cardBelow(promptCards(page), 250);
const before = (await box(card)).top;
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(libraryBar(page).locator('strong')).toHaveText('1 selected');
expect(inViewport(await box(libraryBar(page)), page)).toBe(true);
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
expect(await card.isVisible()).toBe(true);
});
}
test('the Images panel clears the stuck bar and a focused card is not left under it', async ({ page, request }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 300)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
await expect.poll(async () => (await box(page.locator('#image-inspector'))).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
expect(overlaps(await box(page.locator('#image-inspector')), await box(imageBar(page)))).toBe(false);
// A control scrolled to by focus lands below the bar; the bar's own controls do not move the page.
const y = await page.evaluate(() => scrollY);
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
expect(await page.evaluate(() => scrollY)).toBe(y);
const above = imageCards(page).first().locator('.image-overlays input[type="checkbox"]');
await above.focus();
expect((await box(above)).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
});
test('Back to top shows off the top of a page, goes to the top and hands focus to the page', async ({ page, request }) => {
await openImages(page, request);
await expect(chip(page)).toBeHidden();
await scrollToY(page, 1500);
await expect(chip(page)).toBeVisible();
const loaded = await page.evaluate(() => document.querySelector('#result-count').textContent);
await chip(page).click();
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
await expect(chip(page)).toBeHidden();
await expect(page.locator('#workspace-main')).toBeFocused();
expect(await page.evaluate(() => document.querySelector('#result-count').textContent)).toBe(loaded);
await expect(imageCards(page).first()).toBeInViewport();
});
test('Back to top on the virtualized Library lands on the first card and stays there', async ({ page, request }) => {
await openLibrary(page, request, 200);
// Past 120 loaded prompts the shelf mounts only a window of them (each pass reaches the next page's trigger).
await expect.poll(async () => {
await page.evaluate(() => scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }));
return page.locator('article[data-index="0"]').count();
}, { timeout: 30000 }).toBe(0);
await expect(chip(page)).toBeVisible();
await chip(page).click();
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
await expect.poll(async () => Number(await promptCards(page).first().getAttribute('data-index'))).toBe(0);
await expect(promptCards(page).first()).toBeInViewport();
expect(await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(scrollY)))))).toBe(0);
});
test('Back to top jumps, under reduced motion and otherwise', async ({ page, request }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await openImages(page, request, 20);
await scrollToY(page, 600);
await expect(chip(page)).toBeVisible();
const after = await chip(page).evaluate(node => { node.click(); return scrollY; });
expect(after).toBe(0);
});
for (const viewport of [{ width: 1280, height: 720 }, PHONE]) {
test(`Back to top moves clear of a toast and of the bar at ${viewport.width}px`, async ({ page, request }) => {
await page.setViewportSize(viewport);
await openImages(page, request);
await scrollToY(page, 1500);
await expect(chip(page)).toBeVisible();
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
expect(overlaps(await box(chip(page)), await box(imageBar(page)))).toBe(false);
const alone = await box(chip(page));
await page.evaluate(() => {
document.getElementById('workspace-toast').textContent = 'A much longer message than most, one that keeps going, so that it wraps to more than two lines on the narrowest screen a phone will give this page to show it on, and then some more words again.';
});
await expect(toast(page)).not.toBeEmpty();
await expect.poll(async () => overlaps(await box(chip(page)), await box(toast(page)))).toBe(false);
expect((await box(chip(page))).bottom).toBeLessThanOrEqual((await box(toast(page))).top);
await expect(chip(page)).toBeInViewport();
await page.evaluate(() => { document.getElementById('workspace-toast').textContent = ''; });
await expect.poll(async () => Math.round((await box(chip(page))).bottom)).toBe(Math.round(alone.bottom));
});
}
test('a stuck bar at 390px is one row that keeps every action reachable', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
expect((await box(imageBar(page))).bottom - (await box(imageBar(page))).top).toBeLessThan(80);
const clear = imageBar(page).getByRole('button', { name: 'Clear', exact: true });
await clear.scrollIntoViewIfNeeded();
await expect(clear).toBeInViewport();
await clear.click();
await expect(imageBar(page)).toHaveCount(0);
});
test('the Needs review panel stays with you, and its long content scrolls inside it', async ({ page, request }) => {
await page.setViewportSize({ width: 1400, height: 500 });
await openReview(page, request);
await scrollToY(page, 700);
await expect(reviewPanel(page)).toBeInViewport();
const rect = await box(reviewPanel(page));
expect(rect.top).toBeGreaterThanOrEqual(0);
expect(rect.bottom).toBeLessThanOrEqual(500);
const card = await cardBelow(reviewCards(page), 100);
await card.getByRole('button', { name: /^Inspect / }).click();
const name = await card.locator('.media-info').first().textContent();
expect(name).toBeTruthy();
await expect(reviewPanel(page)).toBeInViewport();
expect((await box(reviewPanel(page))).top).toBe(rect.top);
});
test('at 390px Info on a lower review card brings the panel into view', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openReview(page, request);
await expect(reviewPanel(page)).not.toBeInViewport();
const card = reviewCards(page).nth(3);
await card.scrollIntoViewIfNeeded();
await card.getByRole('button', { name: /^Inspect / }).click();
await expect(reviewPanel(page)).toBeInViewport();
});
// D189: at one column the Images panel follows the whole list: Info goes there, and a batch doesn't push it away.
test('at 390px Info on an image brings its panel into view and loading waits for the next move', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openImages(page, request);
expect(await page.evaluate(() => scrollY)).toBe(0);
await expect(page.locator('#image-inspector')).not.toBeInViewport();
let reads = 0;
page.on('request', item => { if (new URL(item.url()).pathname.endsWith('/images/recent')) reads++; });
const card = imageCards(page).nth(3);
await card.scrollIntoViewIfNeeded();
await card.getByRole('button', { name: /^Inspect / }).click();
await expect(page.locator('#image-inspector')).toBeInViewport();
await expect(page.locator('#viewer-dialog')).toBeHidden();
const held = (await box(page.locator('#image-inspector'))).top;
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect((await box(page.locator('#image-inspector'))).top).toBe(held);
expect(reads).toBe(0);
// The next move releases it: loading resumes.
const next = page.waitForRequest(item => new URL(item.url()).pathname.includes('/images'));
await page.mouse.wheel(0, 40);
await next;
});
// Review's review-state load more is a button, so Library's anchor stays honest through a compensated bar.
test('a Library selection does not jump after a pending height correction', async ({ page, request }) => {
await page.setViewportSize({ width: 1000, height: 800 });
await openLibrary(page, request);
await scrollToY(page, 3000);
const card = await cardBelow(promptCards(page), 250);
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(libraryBar(page)).toBeVisible();
const before = (await box(card)).top;
await page.setViewportSize({ width: 1300, height: 800 });
await expect(card).toBeAttached();
await page.setViewportSize({ width: 1000, height: 800 });
await expect.poll(async () => Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
});
// #531: after a bulk action on a bar, the page shows the refreshed list from the top.
async function selectFarDownImages(page, request) {
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
}
const atTop = page => expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
const applyDialog = async (page, title, value) => {
const dialog = page.getByRole('dialog', { name: title, exact: true });
await dialog.locator('#action-input').fill(value);
await dialog.getByRole('button', { name: /^(Apply|Add)/ }).click();
await expect(dialog).toHaveCount(0);
};
test('Images: Set category returns to the top of the refreshed list', async ({ page, request }) => {
await selectFarDownImages(page, request);
const read = page.waitForRequest(item => item.url().includes('/prompt_manager/images/'));
await imageBar(page).getByRole('button', { name: 'Set category', exact: true }).click();
await applyDialog(page, 'Categorize selected images', 'Studio');
await read;
await atTop(page);
await expect(imageBar(page)).toHaveCount(0);
await expect(imageCards(page).first()).toBeInViewport();
});
test('Images: Add tags returns to the top of the refreshed list when its job ends', async ({ page, request }) => {
await selectFarDownImages(page, request);
await imageBar(page).getByRole('button', { name: 'Add tags', exact: true }).click();
await applyDialog(page, 'Tag selected images', 'sticky-tag');
await atTop(page);
await expect(imageBar(page)).toBeVisible();
});
test('Library: Set category and Add tags return to the top of the reloaded shelf', async ({ page, request }) => {
await openLibrary(page, request);
for (const [button, title, value] of [['Set category', 'Categorize selected prompts', 'Studio'], ['Add tags', 'Tag selected prompts', 'sticky']]) {
await scrollToY(page, 2000);
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
await libraryBar(page).getByRole('button', { name: button, exact: true }).click();
await applyDialog(page, title, value);
await atTop(page);
await expect(libraryBar(page)).toHaveCount(0);
await expect(promptCards(page).first()).toBeInViewport();
}
});
test('Images: Delete… returns to the top of the reloaded list', async ({ page, request }) => {
await selectFarDownImages(page, request);
await imageBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = page.locator('dialog[open]');
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await startAndAwaitJob(page, () => dialog.locator('.dialog-actions button.danger').click());
await expect(toast(page)).toContainText('Removed 1 image from the library.');
await atTop(page);
await expect(page.locator('#bulkbar')).toBeEmpty();
});
test('Library: Delete… returns to the top of the reloaded shelf', async ({ page, request }) => {
await openLibrary(page, request);
await scrollToY(page, 2000);
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
await libraryBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await page.locator('dialog[open] .dialog-actions button.danger').click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await atTop(page);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
@@ -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');
@@ -99,7 +99,7 @@ test('send destination offers Ask each time; unsupported options disabled with t
.toEqual([['Ask each time', false], ['Selected Prompt Manager node', true], ['Create a Prompt Manager (+/-) node', true]]);
await expect(send.locator('select')).toHaveValue('Ask each time');
await expect(send.locator('p.small')).toHaveText(
'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.');
'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.');
const renderer = settingOf(page, 'Host compatibility');
expect(await renderer.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
.toEqual([['Follow host renderer', false], ['Classic', true], ['Nodes 2.0', true]]);
@@ -41,9 +41,6 @@ async function serverJson(request, url) {
return response.json();
}
// `openSources` dismisses the notice itself unless a test asks for it.
test.use({ olderImagesNotice: true });
/** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */
async function openSources(page, { notice = false } = {}) {
if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
-3
View File
@@ -897,8 +897,6 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
// System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed
// `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs
// checks the rest of each row.
test.describe(() => {
test.use({ olderImagesNotice: true });
test('every covered legacy System capability has its native control', async ({ page, request }) => {
// Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is
// missing here.
@@ -909,7 +907,6 @@ test('every covered legacy System capability has its native control', async ({ p
await request.post('/__test__/tagging/reset', { data: {} });
}
});
});
async function findControls(page) {
const covered = readCoverageTable().filter(row => row.Status === 'covered')
@@ -1,313 +0,0 @@
// #532 (TESTING.md D170): ← and → move through the viewer's files wherever focus is, except where the arrows belong to
// what has focus; ⌘ beside the view switch lists the shortcuts in an overlay that closes on its own.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openProduction } from './concept-inventory.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 position = page => viewer(page).locator('.media-position');
const keysButton = page => viewer(page).locator('.dialog-header > button#viewer-keys');
const ALL_KEYS = ['←', '→', 'Space', '+', '-', '0', 'R', 'H', 'F', 'I', '?', 'Esc', 'D', 'Delete'];
const overlay = page => page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true });
test.beforeEach(async ({ page, request, browserName }, testInfo) => {
await startCoverage(page, browserName);
testInfo.manifest = await seedConcept(request);
});
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
async function openImages(page, width = 1600) {
await page.setViewportSize({ width, height: 1100 });
await openProduction(page, 'images', 'dark');
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
await expect(position(page)).toContainText('1 of ');
}
async function movesBothWays(page) {
await page.keyboard.press('ArrowRight');
await expect(position(page)).toContainText('2 of ');
await page.keyboard.press('ArrowLeft');
await expect(position(page)).toContainText('1 of ');
}
async function staysPut(page) {
const before = await position(page).textContent();
await page.keyboard.press('ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
}
test('the arrows move through the files as soon as the viewer opens, with focus on ✕', async ({ page }) => {
await openImages(page);
await expect(viewer(page).locator('#viewer-close')).toBeFocused();
await movesBothWays(page);
});
test('the arrows keep working from every header control and when focus falls to the page', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-mode').click();
await movesBothWays(page);
await viewer(page).locator('#viewer-mode').click();
// Header text is not focusable: the click leaves focus outside any control.
await viewer(page).locator('.dialog-header p').click();
await movesBothWays(page);
await page.evaluate(() => document.activeElement?.blur());
await movesBothWays(page);
});
test('the arrows move from a details button but stay with focused text and with modifier keys', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-details button').first().focus();
await movesBothWays(page);
await viewer(page).locator('#viewer-details p.prompt-text').first().focus();
await staysPut(page);
await viewer(page).locator('#viewer-close').focus();
const before = await position(page).textContent();
await page.keyboard.press('Shift+ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
});
test('⌘ lists the shortcuts; Esc closes only the overlay and the arrows wait for it', async ({ page }) => {
await openImages(page);
await expect(keysButton(page)).toHaveAccessibleName('Keyboard shortcuts');
await expect(keysButton(page)).toHaveAttribute('title', 'Keyboard shortcuts');
await expect(keysButton(page).locator('svg.icon')).toHaveCount(1);
await expect(page.locator('#viewer-keys-dialog')).toHaveCount(0);
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
// A small dialog sized to its content, its title inset from the edge.
const box = await overlay(page).evaluate(node => {
const frame = node.getBoundingClientRect(), title = node.querySelector('h2').getBoundingClientRect();
return { height: frame.height, inset: title.left - frame.left };
});
expect(box.height).toBeLessThan(700);
expect(box.inset).toBeGreaterThan(8);
await staysPut(page);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await movesBothWays(page);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
});
test('the overlay × closes only the overlay; closing the viewer never leaves one behind', async ({ page }) => {
await openImages(page);
await keysButton(page).click();
await overlay(page).getByRole('button', { name: 'Close keyboard shortcuts', exact: true }).click();
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.evaluate(() => { location.hash = '/overview'; });
await expect(page.locator('#viewer-dialog,#viewer-keys-dialog')).toHaveCount(0);
});
test('opening the shortcuts stops the slideshow', async ({ page }) => {
await openImages(page);
const play = viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
await play.click();
await expect(play).toHaveAttribute('aria-label', 'Pause slideshow');
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(play).toHaveAttribute('aria-label', 'Play slideshow');
});
test('the shortcuts button hides at phone width, like the view switch', async ({ page }) => {
await openImages(page, 600);
await expect(keysButton(page)).toBeHidden();
await movesBothWays(page);
});
// ── #532: the toolbar's actions on single keys. Each key clicks the toolbar's own control, so the two cannot disagree.
const stage = page => viewer(page).locator('#viewer-stage');
const image = page => viewer(page).locator('.viewer-canvas img');
const control = (page, name) => viewer(page).getByRole('button', { name, exact: true });
const transform = page => image(page).evaluate(node => node.style.transform + '|' + node.style.width);
const playing = page => viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
const review = page => page.locator('dialog#modal[open]');
/** A key event a browser would keep for itself, or that repeats, sent straight to what has focus. */
const synth = (page, key, init = {}) => page.evaluate(([key, init]) => {
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}, [key, init]);
async function ready(page) {
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'false');
// Zoom opens as the image is viewed; the viewer's first fit to the stage follows a frame later.
let previous;
await expect.poll(async () => {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const settled = await transform(page);
const same = settled === previous;
previous = settled;
return same;
}).toBe(true);
await stage(page).focus();
}
/** Nothing the keys can change has changed. */
const snapshot = async page => ({ position: await position(page).textContent(), transform: await transform(page),
play: await playing(page).getAttribute('aria-label'), classes: await viewer(page).getAttribute('class'),
details: await viewer(page).locator('#viewer-details').isVisible(), modals: await page.locator('dialog[open]').count() });
test('+ - 0 zoom and fit, R rotates, H flips', async ({ page }) => {
await openImages(page);await ready(page);
const fit = await transform(page);
await page.keyboard.press('+');
await expect.poll(() => transform(page)).not.toBe(fit);
const zoomed = await transform(page);
await page.keyboard.press('-');
await expect.poll(() => transform(page)).not.toBe(zoomed);
await page.keyboard.press('+');await page.keyboard.press('0');
await expect.poll(() => transform(page)).toBe(fit);
await page.keyboard.press('r');
await expect.poll(() => transform(page)).toContain('rotate(90deg)');
await page.keyboard.press('Shift+R');
await expect.poll(() => transform(page)).toContain('rotate(180deg)');
await page.keyboard.press('h');
await expect.poll(() => transform(page)).toContain('scaleX(-1)');
});
test('Space plays and pauses on the stage, and activates a focused button instead', async ({ page }) => {
await openImages(page);await ready(page);
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Pause slideshow');
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await control(page, 'Next media').focus();
await page.keyboard.press('Space');
await expect(position(page)).toContainText('2 of ');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
});
test('F switches the view, I shows and hides the details the toggle offers', async ({ page }) => {
await openImages(page);await ready(page);
const details = viewer(page).locator('#viewer-details');
// Centered without the collapse setting: the toolbar has no toggle, so I does nothing.
await page.keyboard.press('i');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('i');
await expect(details).toBeHidden();
await page.keyboard.press('i');
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
test('F does nothing where the view switch is hidden; ? still opens the shortcuts, which fit at 390 px', async ({ page }) => {
await openImages(page, 390);await ready(page);
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
const fit = await overlay(page).evaluate(node => {
const box = node.getBoundingClientRect();
return { inside: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
noSideScroll: node.scrollWidth <= node.clientWidth };
});
expect(fit).toEqual({ inside: true, noSideScroll: true });
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
await overlay(page).locator('dt').last().scrollIntoViewIfNeeded();
await expect(overlay(page).locator('dt').last()).toBeInViewport();
});
test('D downloads the original', async ({ page }) => {
await openImages(page);await ready(page);
const [download] = await Promise.all([page.waitForEvent('download'), page.keyboard.press('d')]);
expect(download.suggestedFilename()).toMatch(/\.(png|jpe?g|webp)$/);
});
test('Delete opens the review and never deletes; every viewer key waits while the review is open', async ({ page }) => {
const writes = [];
page.on('request', request => { if (request.method() !== 'GET' && !request.url().includes('/__test__/')) writes.push(request.url()); });
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
writes.length = 0;
await page.keyboard.press('Delete');
await expect(review(page)).toBeVisible();
// The review reads its preview; repeating the key must not ask again.
await page.waitForLoadState('networkidle');
const opened = writes.length;
for (const key of ['Delete', 'r', 'f', 'i', 'd', '?']) await page.keyboard.press(key);
// With focus fallen to the page, too.
await page.evaluate(() => document.activeElement?.blur());
for (const key of ['Delete', 'ArrowRight', 'r', ' ']) await page.keyboard.press(key === ' ' ? 'Space' : key);
await page.waitForLoadState('networkidle');
expect(writes.length).toBe(opened);
// Inside the review the arrows and Space are its own: they may choose a mode, which only reads a new preview.
await review(page).getByRole('radio').first().focus();
for (const key of ['ArrowRight', 'Space']) await page.keyboard.press(key);
await expect(overlay(page)).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
expect(writes.filter(url => /delete|trash/i.test(url) && !url.endsWith('/delete/preview'))).toEqual([]);
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
});
test('the keys do nothing with Ctrl, Cmd or Alt held, on a repeat, or while the overlay is open', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
for (const modifier of ['ctrlKey', 'metaKey', 'altKey'])
for (const key of ['+', '-', '0', 'r', 'h', ' ', 'f', 'd', 'Delete', '?']) await synth(page, key, { [modifier]: true });
for (const key of [' ', 'r', 'h', 'f', 'd', 'Delete', '?']) await synth(page, key, { repeat: true });
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
for (const key of ['r', 'h', 'f', 'i', '0', 'Delete']) await page.keyboard.press(key);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
expect(await snapshot(page)).toEqual(before);
await expect(viewer(page)).toBeVisible();
});
test('the keys stay out of a form field, raw metadata and a disclosure', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
await viewer(page).locator('#viewer-details').evaluate(node => {
const field = document.createElement('input');field.id = 'probe';node.append(field);
});
await viewer(page).locator('#probe').focus();
await page.keyboard.type('rhfid0+-? ');
await expect(viewer(page).locator('#probe')).toHaveValue('rhfid0+-? ');
await viewer(page).locator('#probe').evaluate(node => node.remove());
const summary = viewer(page).getByText('Workflow & raw metadata', { exact: true });
await summary.focus();
for (const key of ['r', 'f', '0']) await page.keyboard.press(key);
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
});
test('on a video the image keys and Space do nothing, as the toolbar disables them; a focused video keeps its keys', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto('/prompt_manager/workspace#/images');
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
const video = viewer(page).locator('video');
await expect(video).toHaveJSProperty('controls', true);
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'true');
const errors = [];page.on('pageerror', error => errors.push(error.message));
await stage(page).focus();
for (const key of ['+', '-', '0', 'r', 'h', 'Space']) await page.keyboard.press(key);
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await expect(viewer(page).locator('.viewer-canvas')).toHaveCount(0);
expect(errors).toEqual([]);
await video.focus();
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
+1 -1
View File
@@ -32,7 +32,7 @@ const frame = page => viewer(page).evaluate(node => {
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-keys', 'viewer-mode', 'viewer-close']);
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);
+8 -12
View File
@@ -1,7 +1,6 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {mapConceptLayout} from './concept-inventory.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs';
@@ -135,9 +134,7 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await rows(page).last().getByRole('button',{name:'Rename / merge',exact:true}).evaluate(button=>button.focus({preventScroll:true}));
await page.keyboard.press('Tab');
await expect(loadMore(page)).toBeFocused();
// D183: Load more sits below the tag window, so reaching it does not move the window's end into view.
await expect(rows(page)).toHaveCount(100);
await page.keyboard.press('Enter');
// Focus scrolled the end of the list into view, which appends the next page.
await expect(rows(page)).toHaveCount(200);
await expect(loadMore(page)).toBeFocused();
await page.keyboard.press('Enter');
@@ -145,12 +142,11 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
// Reload at the top so restored scroll does not append before the check.
await page.evaluate(()=>window.scrollTo(0,0));
await page.reload();
await expect(rows(page)).toHaveCount(100);
// D183: the end of the list arrives inside the tag window.
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
const scroll=top=>page.evaluate(y=>new Promise(resolve=>{window.scrollTo(0,y ?? document.documentElement.scrollHeight);requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
for(const count of [200,230]) {
// Each new arrival of the end of the list appends one page.
await scroll(0);await scroll(null);
@@ -210,12 +206,12 @@ test('route disposal aborts requests and removes observers',async({page})=>{
await page.route('**/prompt_manager/vocabulary/**',route=>{held.push(route);});
await page.goto(route);
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).length)).toBe(2);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).map(item=>item.disconnected))).toEqual([true,true]);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
await expect(main(page).locator('.two-col')).toHaveCount(0);
expect(await page.evaluate(()=>document.getElementById('workspace-main').classList.contains('vocabulary-main'))).toBe(false);
await page.unroute('**/prompt_manager/vocabulary/**');
@@ -779,7 +775,7 @@ const SIZES=[[1600,1100],[390,844]];
const MOTIONS=['no-preference','reduce'];
async function openConceptTags(page,theme,motion) {
await page.emulateMedia({reducedMotion:motion});
await page.goto('about:blank');await page.goto(`${concept}#tags`);await mapConceptLayout(page);
await page.goto('about:blank');await page.goto(`${concept}#tags`);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await expect(page.locator('#main h1')).toHaveText('Tags & categories');
}
@@ -795,7 +791,7 @@ 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 mapConceptLayout(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
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);
+33 -165
View File
@@ -11,12 +11,12 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole
const sidebarLinks = page => page.locator('aside.sidebar nav a');
const routes = [
['overview', 'Overview', null],
// D29: Images no longer links the legacy gallery.
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
['images', 'Images', null],
// #249 E3: the shelf no longer links the legacy Library.
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
['library', 'Prompt library', null],
['tags', 'Tags & categories', null],
// #249 local Inspect: the native Inspect a file page.
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
['inspect', 'Inspect a file', null],
// #249 System S2: native System; every section native since S12 (D94 retired, asserted below).
['system', 'System', null],
@@ -60,10 +60,6 @@ test.afterEach(async ({ page, browserName }) => {
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
const before = await (await request.get('/prompt_manager/stats')).json();
const total = async path => (await (await request.get(`/prompt_manager/${path}`)).json()).total;
const counts = [['Prompts', await total('library?limit=1')],
['Images', await total('images/output?mode=workspace&limit=1')],
['Tags', await total('vocabulary/tags?limit=1')]];
const mutations = [];
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
@@ -73,11 +69,10 @@ test('workspace starts with real persisted statistics without mutating data', as
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
for (const [label, count] of counts) await expect(statistic(page, label)).toHaveText(String(count));
// #547: a card and the sidebar badge of the page it names show one number.
for (const [route, count] of [['library', counts[0][1]], ['images', counts[1][1]], ['tags', counts[2][1]]]) {
await expect(page.locator(`aside.sidebar nav [data-route="${route}"] .nav-count`)).toHaveText(String(count));
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
}
expect(before.stats.total_prompts).toBe(2);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
expect(mutations).toEqual([]);
await expect(page.locator('iframe')).toHaveCount(0);
@@ -98,106 +93,20 @@ test('all shell destinations deep-link, reload and expose working existing appli
await page.reload();
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
}
// #525: Overview's cards open the native pages, and no workspace page links a legacy one.
for (const [action, hash, title] of [
['Open Images', '#/images', 'Images'],
['Open Prompt library', '#/library', 'Prompt library'],
['Open Tags & categories', '#/tags', 'Tags & categories'],
['Open Inspect a file', '#/inspect', 'Inspect a file'],
['Open System', '#/system/overview', 'System'],
]) {
await page.goto(`${workspace}#/overview`);
const main = page.locator('#workspace-main');
const card = main.getByRole('link', { name: action, exact: true });
await expect(card).toHaveAttribute('href', hash);
await card.click();
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace${hash}$`));
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
}
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true })).toBeVisible();
await expect(page.locator('a[href*="/prompt_manager/admin"], a[href*="/prompt_manager/gallery"]')).toHaveCount(0);
await expect(page.locator('#workspace-main').getByText('Your existing tools remain available')).toHaveCount(0);
// The legacy pages themselves stay at their URLs.
for (const legacy of ['/prompt_manager/admin', '/prompt_manager/gallery', '/prompt_manager/gallery.html']) {
await expect(await request.get(legacy)).toBeOK();
}
});
test('/prompt_manager/web lands on the workspace Library and Back does not bounce through it (#525)', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
await page.evaluate(() => { location.href = '/prompt_manager/web'; });
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/overview$/);
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
});
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
await page.goto(`${workspace}#/system/overview`);
const line = page.locator('#workspace-main .legacy-pages');
await expect(line).toBeVisible();
await expect(line).toContainText('Legacy pages');
const links = line.getByRole('link');
await expect(links).toHaveCount(4);
for (const [name, url, ready] of [
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
['Gallery', '/prompt_manager/gallery', 'body'],
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
]) {
const opened = context.waitForEvent('page');
await line.getByRole('link', { name, exact: true }).click();
const tab = await opened;
await tab.waitForLoadState('domcontentloaded');
const target = new URL(tab.url());
expect(target.pathname + target.hash).toBe(url);
await expect(tab.locator(ready).first()).toBeAttached();
await tab.close();
}
// Only on System's Overview section, and gone with the page.
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(line).toBeHidden();
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
});
const bootFailure = page => page.locator('#workspace-boot-failure');
test('a healthy workspace never shows the load failure message (#525)', async ({ page }) => {
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.waitForFunction(() => document.readyState === 'complete');
await expect(bootFailure(page)).toBeHidden();
});
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
await page.route(pattern, route => route.abort());
await page.goto(`${workspace}#/library`);
const failure = bootFailure(page);
await expect(failure).toBeVisible();
await expect(failure).toHaveAttribute('role', 'alert');
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
await expect(dashboard).toBeVisible();
await expect(failure.getByRole('link')).toHaveCount(4);
await dashboard.click();
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
expect(pageErrors).toEqual([]);
});
}
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
await page.goto(`${workspace}#/library`);
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
// The broken module's own error is this test's premise, not a failure of the page.
page.workspaceErrors.length = 0;
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
await expect(await request.get('/prompt_manager/admin')).toBeOK();
// The legacy metadata page stays reachable from Overview beside the native Inspect page.
const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true });
await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html');
await expect(await request.get('/prompt_manager/gallery.html')).toBeOK();
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
await gallery.click();
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
@@ -378,65 +287,25 @@ test('unavailable host palette falls back to the system theme and tracks system
});
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
const sources = ['**/prompt_manager/library?limit=1', '**/prompt_manager/images/output?mode=workspace&limit=1',
'**/prompt_manager/vocabulary/tags?limit=1'];
let release;
const pending = new Promise(resolve => { release = resolve; });
for (const source of sources) {
await page.route(source, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
}
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
for (const source of sources) {
await page.unroute(source);
await page.route(source, route => route.fulfill({ json: { success: true, total: 0, items: [], images: [], tags: [] } }));
}
await page.unroute('**/prompt_manager/stats');
await page.route('**/prompt_manager/stats', route => route.fulfill({
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
}));
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
for (const label of ['Prompts', 'Images', 'Tags']) {
await expect(statistic(page, label)).toHaveText('0');
}
await expect(page.locator('#workspace-main .empty-state')).toBeVisible();
});
test('a slow or failed Images read leaves the other Overview cards and retries only itself', async ({ page }) => {
const images = '**/prompt_manager/images/output?mode=workspace&limit=1';
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route(images, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
await expect(statistic(page, 'Images')).toHaveText('–');
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await expect(statistic(page, 'Prompts')).toHaveText('2');
await page.unroute(images);
let libraryReads = 0;
page.on('request', request => { if (new URL(request.url()).pathname === '/prompt_manager/library') libraryReads++; });
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(libraryReads).toBe(0);
});
test('opening Overview reads each count once beside the sidebar', async ({ page }) => {
let reads = 0;
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === null) reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(reads).toBe(1);
});
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
@@ -455,9 +324,11 @@ test('leaving a loading overview prevents its delayed result from replacing the
let completed;
const pending = new Promise(resolve => { release = resolve; });
const finished = new Promise(resolve => { completed = resolve; });
await page.route('**/prompt_manager/library?limit=1', async route => {
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ json: { success: true, total: 999, items: [] } }).catch(() => {});
await route.fulfill({ json: {
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
} }).catch(() => {});
completed();
});
await page.goto(`${workspace}#/overview`);
@@ -480,7 +351,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
let reads = 0;
page.on('request', request => {
if (new URL(request.url()).pathname === '/prompt_manager/vocabulary/tags') reads++;
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
@@ -557,8 +428,6 @@ const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Int
'Backup & export', 'Diagnostics & logs'];
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
test.describe(() => {
test.use({ olderImagesNotice: true });
test('System is native: the concept shell, section navigation, sections and the Save row', async ({ page }) => {
const mutations = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
@@ -569,7 +438,7 @@ test('System is native: the concept shell, section navigation, sections and the
// D92: no "Sample configuration" chip.
await expect(main.locator('.heading .chip')).toHaveCount(0);
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
const nav = main.getByRole('group', { name: 'System sections', exact: true });
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
@@ -637,7 +506,6 @@ test('System is native: the concept shell, section navigation, sections and the
await expect(main.locator('.settings-layout')).toHaveCount(1);
expect(mutations).toEqual([]);
});
});
test('Overview opens native System from its System card (Q12)', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
+1 -42
View File
@@ -19,10 +19,6 @@ after the plain ones in this order:
recorder stores ``limited`` (more than 16 candidates, so none are stored).
An A1111 block with two negative lines is not queued any more: it offers
no candidate to choose (#284).
- ``unreadable`` (#536): an item whose stored candidates are all
``unresolved``, as items recorded before the choice rule are, written directly
(the recorder no longer stores one). The Library holds "Golden dunes at
dusk" with its negative, and "Quiet harbor" with two.
- ``unavailable``: a file recorded under the ``temp`` directory while it was a
gallery root; the root is then removed again, so no current root lists it.
@@ -45,7 +41,6 @@ page closed is dropped at such a write.
from __future__ import annotations
import hashlib
import json
import os
import threading
@@ -63,11 +58,10 @@ if TYPE_CHECKING:
from py.api import PromptManagerAPI
POSITIVES = ("Golden dunes at dusk", "Harbor lights at night")
UNREADABLE_REASON = "Unsupported latent ancestry"
NEGATIVE = "Soft negative, blur"
# The prompt the conflict item's file is linked to after it was queued.
FALLBACK = "Misty forest path"
SPECIAL = ("conflict", "limited", "unavailable", "unreadable")
SPECIAL = ("conflict", "limited", "unavailable")
# The concept's first three images (concept app.js `images`) and first prompt.
CONCEPT_IMAGES = (
("alpine_00001.png", (1536, 1152)),
@@ -221,38 +215,6 @@ def _unavailable(api: PromptManagerAPI, root: Path, path: Path) -> None:
GalleryConfig.MONITORING_DIRECTORIES = roots
def _unreadable(api: PromptManagerAPI, path: Path) -> None:
"""Queue ``path`` with candidates no reviewer can apply, and Library prompts."""
facts = path.stat()
spelled = os.path.join(os.path.realpath(path.parent), path.name)
candidates = [
{
"hash": hashlib.sha256(f"unreadable-{sampler}".encode()).hexdigest(),
"sampler_id": sampler,
"state": "unresolved",
"reason": UNREADABLE_REASON,
"positive": None,
"negative": None,
}
for sampler in ("11", "13")
]
api.db.record_review_item(
locator=os.path.normcase(spelled),
spelled_path=spelled,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
size=facts.st_size,
mtime_ns=facts.st_mtime_ns,
epoch=api.db.get_execution_epoch(),
resolver_version=1,
evidence={"reason": "", "resolver_version": 1, "candidates": candidates},
candidate_count=len(candidates),
limited=False,
)
api.db.save_prompt_pair(POSITIVES[0], NEGATIVE)
api.db.save_prompt_pair("Quiet harbor", "grainy")
api.db.save_prompt_pair("Quiet harbor", "washed out")
def seed_review(
api: PromptManagerAPI,
root: Path,
@@ -292,9 +254,6 @@ def seed_review(
path = (root / "temp" if kind == "unavailable" else output) / relative
if kind == "conflict":
_linked_after_queueing(api, path)
elif kind == "unreadable":
_png(path, (70, 40, 120))
_unreadable(api, path)
elif kind == "limited":
many = tuple(f"{POSITIVES[0]} {n}" for n in range(LIMITED_SAMPLERS))
_png(path, (90, 90, 90), graph=_graph(positives=many))
+3 -3
View File
@@ -210,14 +210,14 @@ test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page },
}
});
test(`${label} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
await addEditor(page, type);
const popupPromise = page.waitForEvent('popup');
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
+3 -6
View File
@@ -93,7 +93,7 @@ test('local file roles send exactly across renderer transitions',async({page,con
} finally {await restore(page);}
}
}
// A connected destination input blocks the whole send: neither role changes, nothing queues or loads.
// A connected destination input rejects the whole preview: neither role changes, nothing queues or loads.
const link=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text');
@@ -108,11 +108,8 @@ test('local file roles send exactly across renderer transitions',async({page,con
const before=await nodeState(page,id);
expect(before.widgets).toMatchObject({text:'Before positive',negative_text:'Before negative'});
const rail=await inspect(workspace,FILES[1]);
// #535: the linked node is not offered, and the status line says why.
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender).toContainText(/has its negative prompt linked to another node/);
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
const sender=await preview(workspace,rail);
await expect(sender.getByRole('alert')).toContainText(/connected/i);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const rejected=await nodeState(page,id);
expect(rejected.effects).toEqual({queue:0,load:0});
+4 -3
View File
@@ -117,9 +117,10 @@ test('native editor preview sends exact saved roles across renderer transitions'
try {
await send.click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
// #535: the linked node is not offered, and the status line says why.
await expect(sender).toContainText(/has its negative prompt linked to another node/);
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.getByRole('alert')).toContainText(/connected/i);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const rejected=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
+2 -3
View File
@@ -55,10 +55,9 @@ test('paired default layout uses equal editors and follows real host themes',asy
}
});
test('paired explicit Save persists an empty negative through actual API, gives a new prompt its metadata and preserves local draft',async({page,request},testInfo)=>{
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByText('Metadata · category / tags',{exact:true}).click();await root.getByRole('combobox',{name:'Category',exact:true}).fill('Saved category');await root.getByRole('combobox',{name:'Tags',exact:true}).fill('saved-tag');await root.getByRole('button',{name:'Save pair',exact:true}).click();
test('paired explicit Save persists an empty negative through actual API and preserves local draft',async({page,request},testInfo)=>{
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();const response=await request.get(`/prompt_manager/pairs?text=${encodeURIComponent(text)}`);expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:text,negative_text:''})]));expect(await values(page,id)).toMatchObject({text,negative_text:''});
const library=await (await request.get(`/prompt_manager/library?text=${encodeURIComponent(text)}&limit=10`)).json();expect(library.items).toEqual(expect.arrayContaining([expect.objectContaining({category:'Saved category',tags:['saved-tag']})]));
});
test('paired library filters search both texts, persist on reload and keep editor metadata separate',async({page,request},testInfo)=>{
+3 -63
View File
@@ -84,7 +84,7 @@ test('all node schemas and extension entrypoints load from the real host', async
});
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
@@ -95,74 +95,14 @@ for (const type of ['PromptManager', 'PromptManagerText']) {
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
}
test('top-bar PM button opens the workspace in either renderer', async ({ page }) => {
const button = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
await expect(button).toBeVisible();
const popupPromise = page.waitForEvent('popup');
await button.click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.close();
});
for (const [type, buttonName] of [['PromptManager', '🌐 Web UI'], ['PromptManagerFocused', 'Web UI ↗'], ['PromptManagerPair', 'Web UI ↗']]) {
test(`${type} and the PM button open the workspace after switching renderers both ways, a workflow reload and node removal`, async ({ page }, testInfo) => {
const id = await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
node.setSize([900, 800]);
app.canvas.centerOnNode(node);
return node.id;
}, type);
const webUi = page.getByRole('button', { name: buttonName, exact: true });
const pm = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
const opensWorkspace = async button => {
await expect(button).toHaveCount(1);
await expect(button).toBeVisible();
const popupPromise = page.waitForEvent('popup');
await button.click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.close();
};
await opensWorkspace(webUi);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await opensWorkspace(webUi);
await opensWorkspace(pm);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(app.graph.serialize());
});
await opensWorkspace(webUi);
await opensWorkspace(pm);
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(webUi).toHaveCount(0);
await opensWorkspace(pm);
});
}
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
+2 -69
View File
@@ -68,14 +68,8 @@ test('native image inspector previews and sends a pair into real visible widgets
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);
// #535: the window is named by its tab, and one window with one node needs no choosing.
const tabTitle=await page.title();
await expect(hosts.locator('option').nth(1)).toHaveText(tabTitle);
expect(tabTitle).not.toContain('PromptManager');
await expect(hosts).not.toHaveValue('');
await expect(sender.getByRole('combobox',{name:'Destination node',exact:true})).not.toHaveValue('');
await expect(sender.getByRole('status')).toContainText(`in ${tabTitle}. Apply to send it, or preview the changes first.`);
await expect(sender.getByRole('combobox',{name:'Destination node',exact:true}).locator('option').nth(1)).toHaveText(new RegExp(`^#${id} `));
await hosts.selectOption({index:1});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
@@ -92,66 +86,5 @@ test('native image inspector previews and sends a pair into real visible widgets
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')});
// #535 D175: Apply without Preview, one click, and a double click sends once.
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});
const find0=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
const positiveBox=nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true});
await positiveBox.fill('Typed before one click');
await find0.click();
await expect(apply).toBeEnabled();
await apply.dblclick();
await expect(sender).toContainText('Sent to ComfyUI.');
await expect(positiveBox).toHaveValue(positive);
await expect(apply).toBeDisabled();
// Edited in ComfyUI after the send: a stray second send would overwrite this.
await positiveBox.fill('Typed after the send');
await expect(positiveBox).toHaveValue('Typed after the send');
// A changed workflow since discovery refuses through one-click Apply, and writes nothing.
await find0.click();
await expect(apply).toBeEnabled();
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
await apply.click();
await expect(sender.getByRole('alert')).toContainText(/workflow changed/i);
await expect(positiveBox).toHaveValue('Typed after the send');
// A field linked after discovery refuses the same way.
await find0.click();
await expect(apply).toBeEnabled();
await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name==='negative_text');
if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text'));
},id);
await apply.click();
await expect(sender.getByRole('alert')).toContainText(/connected/i);
await expect(positiveBox).toHaveValue('Typed after the send');
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.disconnectInput(node.inputs.findIndex(input=>input.name==='negative_text'));},id);
// Two identical nodes are never selected for you, and read "#<id> <title>".
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
await find0.click();
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
await expect(targets.locator('option')).toHaveCount(3);
await expect(targets).toHaveValue('');
await expect(apply).toBeDisabled();
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
// #535: the status line says why a window cannot receive.
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name==='text');
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
},id);
await find.click();
await expect(sender).toContainText(/has its positive prompt linked to another node/);
await expect(preview).toBeDisabled();
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');app.graph.clear();});
await find.click();
await expect(sender).toContainText('has no Prompt Manager node.');
await expect(preview).toBeDisabled();
} finally {await workspace.close();}
});
+5 -5
View File
@@ -8,7 +8,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
} });
expect(saved.ok()).toBe(true);
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
const workspace = await context.newPage();
const errors = [];
workspace.on('pageerror', error => errors.push(error.message));
@@ -16,7 +16,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await workspace.goto('/prompt_manager/workspace#/overview');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(library.total.toLocaleString());
.toHaveText(stats.total_prompts.toLocaleString());
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
const imageResponse = workspace.waitForResponse(response => {
@@ -37,11 +37,11 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
}
// D29: Images no longer links the legacy gallery; #525: nor does Overview. The page stays at its URL.
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
await workspace.evaluate(() => { location.hash = '/overview'; });
const imagesCard = workspace.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true });
await expect(imagesCard).toHaveAttribute('href', '#/images');
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
await workspace.evaluate(() => { location.hash = '/images'; });
await workspace.reload();
-2
View File
@@ -122,7 +122,6 @@
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
"POST /prompt_manager/images/link",
"POST /prompt_manager/images/removed/return",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/jobs/autotag",
@@ -154,7 +153,6 @@
"POST /prompt_manager/restore/apply",
"POST /prompt_manager/restore/preview",
"POST /prompt_manager/review/items/{item_id}/decision",
"POST /prompt_manager/review/items/{item_id}/link",
"POST /prompt_manager/save",
"POST /prompt_manager/saved-views",
"POST /prompt_manager/scan",
-3
View File
@@ -21,14 +21,11 @@ export function mountedDom() {
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
focus(){this.focused=true;}
click(){this.fire('click');}
getClientRects(){return this.isConnected&&!this.layoutHidden?[{}]:[];}
showModal(){this.open=true;}
close(){this.open=false;this.fire('close');}
contains(node){return node===this||this.children.some(c=>c.contains(node));}
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
querySelectorAll(selector){return walk(this,n=>n.tagName===selector);}
pause(){this.paused=true;}
load(){this.loaded=true;}
play(){this.played=true;return Promise.resolve();}
+1 -1
View File
@@ -13,7 +13,7 @@ function dom() {
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
click(target){this.listeners.get('click')?.({target});}
}
const previous=global.document,toast=new Element('div');global.document={querySelectorAll:()=>[],createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
return {Element,walk,toast,restore:()=>{global.document=previous;}};
}
-19
View File
@@ -151,22 +151,3 @@ test('callback failure preserves serialization normalized between preview and ap
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
});
test('listTargets reports which roles are linked to an upstream node',async()=>{
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
const widgets=[{name:'text',value:''},{name:'negative_text',value:''}];
const app={graph:{_nodes:[{id:1,type:'PromptManagerPair',widgets,inputs:[{name:'text',link:null},{name:'negative_text',link:4}]},{id:2,type:'PromptManager',widgets,inputs:[]}]}};
const [pair,plain]=createPromptAdapter(app,{runtime:{}}).listTargets();
assert.deepEqual(pair.linkedRoles,['negative']);assert.deepEqual(plain.linkedRoles,[]);
});
test('the revision moves when the graph is edited and when a load replaces the node, even at the same version',async()=>{
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
const node=()=>({id:1,type:'PromptManager',widgets:[{name:'text',value:''}],inputs:[]});
const app={graph:{_version:9,_nodes:[node()]}};
const adapter=createPromptAdapter(app,{runtime:{}});
const first=adapter.listTargets()[0].revision;
assert.equal(adapter.preview('1',{positive:'x'}).preview.revision,first);
app.graph._version=10;const edited=adapter.listTargets()[0].revision;assert.notEqual(edited,first);
app.graph={_version:10,_nodes:[node()]};assert.notEqual(adapter.listTargets()[0].revision,edited);
});
+1 -26
View File
@@ -1,7 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installPromptBridge } from '../../web/js/prompt-bridge-host.js';
import { createBridgeClient, createBridgeHost } from '../../web/js/shared/toolkit/prompt-bridge.js';
function fixture() {
const runtime = new EventTarget();
@@ -20,10 +19,7 @@ test('host uses the exact deployment prefix and explicit pack label', () => {
assert.equal(f.hosts.length, 1);
assert.equal(f.hosts[0].config.namespace, '/team/');
assert.equal(f.hosts[0].config.origin, 'https://comfy.example');
assert.equal(f.hosts[0].config.label, 'My workflow');
f.runtime.document.title = '';
assert.equal(f.hosts[0].config.label, 'ComfyUI');
f.runtime.document.title = 'My workflow';
assert.equal(f.hosts[0].config.label, 'PromptManager — My workflow');
assert.equal(f.hosts[0].config.adapter, f.adapter);
assert.equal(installPromptBridge(app, f.options), controller);
controller.dispose(); controller.dispose();
@@ -70,24 +66,3 @@ test('deployment namespace rejects external origins and unexpected paths', () =>
assert.equal(f.hosts.length, 0);
}
});
test('each discovery reports the title the tab has now, through the real Toolkit host', async () => {
const f = fixture();
f.runtime.location = { origin: 'https://comfy.example', href: 'https://comfy.example/' };
f.options.baseUrl = '/prompt_manager/';
const listeners = new Set();
const channelFactory = () => { const own = new Set(); return {
addEventListener(type, listener) { if (type === 'message') { own.add(listener); listeners.add(listener); } }, removeEventListener(type, listener) { listeners.delete(listener); },
postMessage(data) { for (const listener of [...listeners]) if (!own.has(listener)) queueMicrotask(() => listener({ data: structuredClone(data), origin: 'https://comfy.example' })); }, close() {},
}; };
// Not spread: a copy would freeze the title the host must keep reading.
f.options.hostFactory = config => { config.channelFactory = channelFactory; return createBridgeHost(config); };
f.adapter.listTargets = () => []; f.adapter.preview = () => {}; f.adapter.apply = () => {};
f.runtime.document.title = 'ComfyUI';
const controller = installPromptBridge({}, f.options);
const client = createBridgeClient({ namespace: '/', origin: 'https://comfy.example', channelFactory });
f.runtime.document.title = '*Unsaved Workflow - ComfyUI';
try {
assert.equal((await client.discover({ waitMs: 100 }))[0].label, '*Unsaved Workflow - ComfyUI');
} finally { client.dispose(); controller.dispose(); }
});
+8 -114
View File
@@ -11,17 +11,18 @@ function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
}
test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
const {createSendSession} = await source; const s=setup(createSendSession);
await s.session.discover(); assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');
await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
await s.session.preview(); assert.equal(s.calls.length,0);
s.session.selectTarget('one');
s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
await s.session.apply();assert.equal(s.calls.length,0);
await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
});
test('pair roles filter incompatible nodes and select the sole destination', async()=>{
test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
assert.equal(s.session.state.targetId,'pair');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
});
test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
@@ -43,127 +44,20 @@ test('only matching explicit acknowledgements count as success',async()=>{
}
});
test('discovery, empty results and stale preview errors remain actionable',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No ComfyUI window answered/i);s.session.dispose();
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
});
test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
});
test('pending apply explains closing cannot undo the sent update',async()=>{
const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
});
const window1=(targets,label='Flow - ComfyUI',hostId='a')=>({hostId,label,targets});
const pm=(id,roles=['positive'],extra={})=>({id,label:`Node ${id}`,roles,...extra});
async function discovered(createSendSession,found,roles={positive:'new'}) {
const s=setup(createSendSession,{discover:async()=>found},roles);await s.session.discover();return s;
}
test('one window with one eligible node is selected so Preview is available at once',async()=>{
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')])]);
assert.equal(s.session.state.hostId,'a');assert.equal(s.session.state.targetId,'1');
assert.match(s.session.state.message,/#1 Node 1 in Flow - ComfyUI.*Apply/);
await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
});
test('several windows are left to choose, and choosing one with a single node selects that node',async()=>{
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'A','a'),window1([pm('2'),pm('3')],'B','b')]);
assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a ComfyUI window/);
s.session.selectHost('a');assert.equal(s.session.state.targetId,'1');
s.session.selectHost('b');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a destination node in B/);
s.session.selectHost('');assert.match(s.session.state.message,/Choose a ComfyUI window/);
s.session.selectHost('b');s.session.selectTarget('2');s.session.selectTarget('');assert.match(s.session.state.message,/Choose a destination node/);s.session.dispose();
});
test('a window that cannot receive says why, naming the window and the node',async()=>{
const {createSendSession}=await source;
let s=await discovered(createSendSession,[window1([])]);
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
s=await discovered(createSendSession,[window1([]),window1([],'Other','b')]);
assert.match(s.session.state.message,/None of the 2 ComfyUI windows has a Prompt Manager node/);s.session.dispose();
s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']})])]);
assert.match(s.session.state.message,/Node 1 in Flow - ComfyUI has its positive prompt linked/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
s=await discovered(createSendSession,[window1([pm('1')])],{positive:'p',negative:'n'});
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node that accepts a positive and negative prompt.*\(\+\/-\)/);s.session.dispose();
});
test('a linked node is not offered beside an eligible one, and an unlinked role still is',async()=>{
const {createSendSession}=await source;
let s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']}),pm('2')])]);
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['2']);assert.equal(s.session.state.targetId,'2');s.session.dispose();
s=await discovered(createSendSession,[window1([pm('1',['positive','negative'],{linkedRoles:['negative']})])]);
assert.equal(s.session.state.targetId,'1');s.session.dispose();
});
test('an auto-selected destination says how many other nodes cannot receive, and the review names the destination',async()=>{
const {createSendSession}=await source;
const s=await discovered(createSendSession,[window1([pm('2')],'A','a'),window1([pm('1',['positive'],{linkedRoles:['positive']})],'B','b')]);
assert.equal(s.session.state.targetId,'2');assert.match(s.session.state.message,/1 other Prompt Manager node cannot receive/);
await s.session.preview();s.session.dispose();
const named=setup(createSendSession,{discover:async()=>[window1([pm('one')])],preview:async(_,id)=>({ticket:'t',target:{id,label:'Live title'},fields:[{role:'positive',before:'',after:'new'}]})});
await named.session.discover();await named.session.preview();assert.match(named.session.state.message,/Sending to #one Live title in Flow - ComfyUI/);named.session.dispose();
});
test('window order and numbering do not depend on which tab answered first',async()=>{
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'T','b'),window1([pm('2')],'T','a')]);
assert.deepEqual(s.session.state.hosts.map(host=>[host.hostId,host.label]),[['a','T'],['b','T (window 2)']]);s.session.dispose();
});
const applied=calls=>calls.filter(call=>call[0]==='apply').length;
test('Apply without Preview fetches the ticket and applies in one click',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
await s.session.discover();await s.session.apply();
assert.equal(s.session.state.phase,'success');assert.deepEqual(s.calls.map(call=>call[0]),['preview','apply']);s.session.dispose();
});
test('a double click applies once, during the send and after it',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
await s.session.discover();const first=s.session.apply();const second=s.session.apply();await Promise.all([first,second]);
assert.equal(applied(s.calls),1);await s.session.apply();assert.equal(applied(s.calls),1);s.session.dispose();
});
test('Apply after a preview uses its ticket and does not preview again',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
await s.session.discover();await s.session.preview();await s.session.apply();
assert.deepEqual(s.calls.map(call=>call[0]),['preview','apply']);assert.equal(s.session.state.phase,'success');s.session.dispose();
});
test('Apply needs a destination',async()=>{
const {createSendSession}=await source;const s=setup(createSendSession);
await s.session.discover();await s.session.apply();assert.equal(s.calls.length,0);s.session.dispose();
});
test('a refusal at the fetched preview says so plainly and writes nothing; Apply can try again',async()=>{
const {createSendSession}=await source;let refuse=true;
const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])],preview:async(_,id)=>{if(refuse)throw Error('positive is connected to an upstream input.');return{ticket:'t',target:{id},fields:[{role:'positive',before:'',after:'new'}]};}});
await s.session.discover();await s.session.apply();
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/connected/);assert.doesNotMatch(s.session.state.message,/may have applied/);
refuse=false;await s.session.apply();assert.equal(s.session.state.phase,'success');s.session.dispose();
});
test('a host refusal at apply is a refusal; only a lost reply is an unknown outcome',async()=>{
const {createSendSession}=await source;
const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])],apply:async()=>{throw Error('Workflow or prompt changed. Preview again.');}});
await s.session.discover();await s.session.apply();assert.match(s.session.state.message,/Workflow or prompt changed/);assert.doesNotMatch(s.session.state.message,/may have applied/);s.session.dispose();
const lost=setup(createSendSession,{discover:async()=>[window1([pm('one')])],apply:async()=>{throw Error('Bridge request timed out');}});
await lost.session.discover();await lost.session.apply();assert.match(lost.session.state.message,/may have applied/);
await lost.session.apply();assert.equal(lost.calls.filter(call=>call[0]==='preview').length,1);
lost.session.selectTarget('one');await lost.session.apply();assert.equal(lost.calls.filter(call=>call[0]==='preview').length,2);lost.session.dispose();
});
test('a workflow changed since discovery refuses before anything is written',async()=>{
const {createSendSession}=await source;
for(const op of ['apply','preview']){
const s=setup(createSendSession,{discover:async()=>[window1([pm('one',['positive'],{revision:'1:4'})])],preview:async(_,id)=>({ticket:'t',revision:'1:5',target:{id},fields:[{role:'positive',before:'',after:'new'}]})});
await s.session.discover();await s.session[op]();
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/workflow changed/i);assert.equal(applied(s.calls),0);s.session.dispose();
}
const stale=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});await stale.session.discover();await stale.session.apply();assert.equal(stale.session.state.phase,'success');stale.session.dispose();
});
test('two or more eligible nodes are never auto-selected, and each option shows id and title',async()=>{
const {createSendSession}=await source;const twin=id=>({id,label:'Prompt Manager (+/-)',roles:['positive']});
const s=await discovered(createSendSession,[window1([twin('72'),twin('73')])]);
assert.equal(s.session.state.targetId,'');assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.label),['#72 Prompt Manager (+/-)','#73 Prompt Manager (+/-)']);
await s.session.apply();assert.equal(s.calls.length,0);s.session.dispose();
const one=await discovered(createSendSession,[window1([twin('72')])]);assert.equal(one.session.state.hosts[0].targets[0].label,'#72 Prompt Manager (+/-)');assert.equal(one.session.state.targetId,'72');one.session.dispose();
});
test('same-named windows and nodes stay distinguishable',async()=>{
const {createSendSession}=await source;const twin=id=>({id,label:'Same',roles:['positive']});
const s=await discovered(createSendSession,[window1([twin('7'),twin('8')],'T','a'),window1([pm('1')],'T','b')]);
assert.deepEqual(s.session.state.hosts.map(host=>host.label),['T','T (window 2)']);
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.label),['#7 Same','#8 Same']);s.session.dispose();
});
function fakeDOM() {
class Node extends EventTarget {
constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
@@ -195,7 +89,7 @@ test('native sender dialog uses prefixed namespace, full safe text, explicit cho
assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
assert.equal(host.value,'host');assert.equal(target.value,'');target.value='one';target.dispatchEvent(new Event('change'));
assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='<unsafe>new</unsafe>'));
const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
-34
View File
@@ -1,34 +0,0 @@
// #525: where the canvas buttons open PromptManager (web/js/workspace-entry.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = import('../../web/js/workspace-entry.js');
const state = import('../../web/js/workspace/state.mjs');
test('the canvas entry route is the workspace Library, a native route', async () => {
const { WORKSPACE_ENTRY_ROUTE } = await source;
const { resolveRoute } = await state;
assert.equal(WORKSPACE_ENTRY_ROUTE, '/prompt_manager/workspace#/library');
const [pathname, hash] = WORKSPACE_ENTRY_ROUTE.split('#');
assert.equal(pathname, '/prompt_manager/workspace');
assert.equal(resolveRoute(`#${hash}`).id, 'library');
});
test('/prompt_manager/web redirects to the same route, under any mount prefix', async () => {
const { WORKSPACE_ENTRY_ROUTE } = await source;
const html = fs.readFileSync(path.join(__dirname, '../../web/index.html'), 'utf8');
const [, pattern, flags, replacement] = html.match(/location\.replace\(\s*window\.location\.pathname\.replace\(\/(.+)\/([a-z]*), '([^']+)'\)/);
const redirect = pathname => pathname.replace(new RegExp(pattern, flags), replacement);
assert.equal(redirect('/prompt_manager/web'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/prompt_manager/web/'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/prompt_manager/index.html'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/sub/prompt_manager/web'), `/sub${WORKSPACE_ENTRY_ROUTE}`);
});
test('no canvas entry point still names a legacy page', () => {
for (const file of ['web/comfy/prompt_manager.js', 'web/js/focused-node.js', 'web/js/classic-node.js']) {
const code = fs.readFileSync(path.join(__dirname, '../..', file), 'utf8');
assert.doesNotMatch(code, /\/prompt_manager\/(?:admin|gallery|web)\b/, file);
}
});
+3 -3
View File
@@ -79,9 +79,9 @@ test('Show in folder needs the full listing identity (D38)',async()=>{
});
test('Show in folder outcomes use the D38 toasts',async()=>{
const {revealOutcome}=await load();
assert.equal(revealOutcome(null),'Opened the folder on the computer running ComfyUI.');
assert.equal(revealOutcome({status:403,code:'not-same-origin'}),'The folder was not opened: this page\'s address does not match the server\'s.');
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on the computer running ComfyUI.');
assert.equal(revealOutcome(null),'Opened the folder on this computer.');
assert.equal(revealOutcome({status:403,code:'not-local'}),'Show in folder works only in a browser on the computer running ComfyUI.');
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on this computer.');
assert.equal(revealOutcome({status:429,code:'busy'}),'The folder is already opening.');
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
@@ -876,63 +876,3 @@ test('retry after a changed storage, file or expired capture observes again and
await settle();
assert.equal(c.state.manual, 'Minimal, warm_light');
});
test('#515: text typed while the first observation is pending is kept, after the saved tags', async () => {
const { createImageTaggingController } = await load();
const answers = [deferred(), deferred()];
let n = 0;
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise }));
// An untagged file: the typed text is the whole draft and Save sends it.
const bare = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' });
const first = bare.open();
bare.setManual('dunes');
answers[0].resolve(response(200, observed({ tags: [], tag_revision: 0 })));
await first;
await settle();
assert.equal(bare.state.phase, 'ready');
assert.equal(bare.state.manual, 'dunes');
assert.equal(bare.state.statusText, '', 'nothing changed elsewhere');
await bare.save();
await settle();
assert.deepEqual(h.of('PUT', TAGS).at(-1).body.tags, ['dunes']);
// A tagged file: its saved tags lead, so a Save never drops tags the user had not seen yet.
const tagged = createImageTaggingController({ api: h.api, target: TARGET });
const second = tagged.open();
tagged.setManual('dunes, minimal');
answers[1].resolve(response(200, observed()));
await second;
await settle();
assert.equal(tagged.state.manual, 'Minimal, warm_light, dunes');
assert.equal(tagged.state.statusText, '');
});
test('#515: a kept draft takes the text typed while the reopen observes', async () => {
const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(409, 'file-changed', 'The file changed.') });
c.setManual('kept draft');
await c.save();
await settle();
const reopened = c.retry();
c.setManual('kept draft, later');
await reopened;
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'kept draft, later');
});
test('#515: text typed before Retry after a failed first observation follows the saved tags', async () => {
const { createImageTaggingController } = await load();
let n = 0;
const h = harness(defaultRoutes({
[`POST ${OBSERVE}`]: () => (n++ === 0 ? fail(500, 'internal', 'boom') : response(200, observed())),
}));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
await settle();
assert.equal(c.state.phase, 'observe-error');
c.setManual('dunes');
await c.retry();
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'Minimal, warm_light, dunes');
assert.equal(c.state.statusText, '');
});
-2
View File
@@ -65,8 +65,6 @@ test('sentences follow the mode and the backup policy', () => {
assert.deepEqual(deleteSentences(plan(), 'trash'), [SENTENCES.trash, USE_LIBRARY_ONLY]);
const nowhere = plan({ trash: { count: 0 }, trash_refused: { count: 3, rows: [] } });
assert.deepEqual(deleteSentences(nowhere, 'trash'), [SENTENCES.trash, NO_TRASH_HERE, USE_LIBRARY_ONLY]);
// #500: no "yet" (Windows is unavailable by design); each file's reason says why.
assert.equal(NO_TRASH_HERE, 'Moving to the trash is not available for these files; each one lists why.');
assert.deepEqual(deleteSentences(plan({ trash_refused: { count: 0 } }), 'trash'), [SENTENCES.trash]);
});
-91
View File
@@ -1,91 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
// #498 (D162): Images › Show removed images › Return to library: the words, the request and the toasts.
const loaded = await import('../../web/js/workspace/images-return.mjs').catch(() => null);
const module = () => { assert.ok(loaded, 'images-return.mjs is not implemented'); return loaded; };
const pageScope = () => {
const controller = new AbortController();
return { signal: controller.signal, cleanup: () => {}, abort: () => controller.abort() };
};
const image = (path, root_index = 0) => ({ path, root_index, root_scope: 's0', filename: path });
test('the toggle, the caption and the empty view', () => {
const { toggleLabel, removedCaption, removedEmpty, RETURN_LABEL } = module();
assert.equal(toggleLabel(null), 'Show removed images');
assert.equal(toggleLabel(1234), 'Show removed images (1,234)');
assert.equal(removedCaption(1), '1 removed image · files on disk, hidden from Images');
assert.equal(removedCaption(3), '3 removed images · files on disk, hidden from Images');
assert.equal(removedEmpty(false), 'No removed images.');
assert.equal(removedEmpty(true), 'No removed images match.');
assert.equal(RETURN_LABEL, 'Return to library');
});
test('the finished toast says what came back and what did not', () => {
const { finishedToast, startToast } = module();
assert.equal(startToast(2), 'Returning 2 images to the library…');
assert.equal(finishedToast({ state: 'finished', counters: { added: 1 } }, 2),
'Returned 2 images to the library; 1 image linked to its prompt. Tags, categories, collections and favorites do not come back.');
assert.equal(finishedToast({ state: 'finished', counters: { added: 3 } }, 3),
'Returned 3 images to the library; 3 images linked to their prompts. Tags, categories, collections and favorites do not come back.');
assert.equal(finishedToast({ state: 'finished', counters: {} }, 1),
'Returned 1 image to the library. Tags, categories, collections and favorites do not come back.');
assert.match(finishedToast({ state: 'failed', status_line: 'Disk error' }, 1), /^Returning images stopped: Disk error\.$/);
assert.match(finishedToast({ state: 'cancelled' }, 1), /cancelled/);
});
test('refusals name the cause', () => {
const { returnFailure, NONE_REMOVED, TOO_MANY, SOURCE_CHANGED, RETURN_FAILED } = module();
assert.equal(returnFailure({ code: 'no-images' }), NONE_REMOVED);
assert.equal(returnFailure({ code: 'too-many-images' }), TOO_MANY);
assert.equal(returnFailure({ code: 'source-changed' }), SOURCE_CHANGED);
assert.equal(returnFailure({ code: 'busy', message: 'Scan for metadata is running.' }), 'Scan for metadata is running.');
assert.equal(returnFailure(new Error('x')), RETURN_FAILED);
});
test('over 500 refuses with the toast before any request', async () => {
const { returnImages, TOO_MANY } = module();
const toasts = [], posts = [];
const api = { post: async (...args) => { posts.push(args); return {}; } };
await returnImages({ api, scope: pageScope(), filter: { q: '' }, count: 501, notify: text => toasts.push(text) });
assert.deepEqual(toasts, [TOO_MANY]);
assert.deepEqual(posts, []);
});
test('items post their identities; a filter posts the removed view; the job is tracked and followed', async () => {
const { returnImages, RETURN_URL } = module();
const toasts = [], posts = [], ended = [];
let listener = null;
const job = { id: 'j1', title: 'Return images to the library', state: 'running', counters: {} };
const store = {
track: started => started,
find: () => job,
subscribe: fn => { listener = fn; return () => { listener = null; }; },
};
const api = { post: async (url, body) => { posts.push([url, body]); return { job, returned: 2, not_removed: 0 }; } };
const started = await returnImages({ api, scope: pageScope(), store, images: [image('a.png'), image('b.png', 1)],
notify: text => toasts.push(text), onReturned: done => ended.push(done) });
assert.equal(started, job);
assert.deepEqual(posts[0], [RETURN_URL, { items: [
{ root_index: 0, root_scope: 's0', path: 'a.png' }, { root_index: 1, root_scope: 's0', path: 'b.png' }] }]);
assert.deepEqual(toasts, ['Returning 2 images to the library…']);
job.state = 'finished'; job.counters = { added: 2 };
listener();
assert.deepEqual(ended, [job]);
assert.match(toasts[1], /^Returned 2 images to the library; 2 images linked/);
await returnImages({ api, scope: pageScope(), filter: { q: 'x' }, count: 2, notify: () => {} });
assert.deepEqual(posts[1][1], { filter: { q: 'x', removed: '1' } });
});
test('a refused return toasts, releases the trigger and changes nothing', async () => {
const { returnImages, NONE_REMOVED } = module();
const toasts = [];
const attributes = new Map();
const trigger = { getAttribute: name => attributes.get(name) ?? null, setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: name => attributes.delete(name) };
const api = { post: async () => { throw Object.assign(new Error('no'), { code: 'no-images' }); } };
const result = await returnImages({ api, scope: pageScope(), images: [image('a.png')], trigger, notify: text => toasts.push(text) });
assert.equal(result, null);
assert.deepEqual(toasts, [NONE_REMOVED]);
assert.equal(trigger.getAttribute('aria-disabled'), null);
});
@@ -69,16 +69,12 @@ function fakeDOM() {
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
getElementById: id => (id === 'workspace-toast' ? toast : null),
querySelectorAll: () => [],
};
return { Element, body, toast, restore() { globalThis.document = previous; } };
}
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
function mountBar(dom, items, checkedKeys, options = {}) {
// The bar publishes its height for the sticky chrome (D186); nothing in this DOM ever resizes.
globalThis.ResizeObserver ??= class { observe() {} disconnect() {} };
document.documentElement ??= { style: { setProperty() {}, removeProperty() {} } };
const scope = createScope();
const grid = new dom.Element('div');
const focusFallback = new dom.Element('h1');
+7 -184
View File
@@ -42,13 +42,8 @@ test('the grid shows each row only when it applies, with protected titles in sel
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
// #512: a row with nothing in it is left out.
const archiveOnly = reviewOf(preview({archive: [2]}));
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Archive']);
const deleteOnly = reviewOf(preview({delete: [1]}));
assert.deepEqual(reviewGrid(deleteOnly).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
const keptOnly = reviewOf(preview({protected: [7]}));
assert.deepEqual(reviewGrid(keptOnly, id => names[id]), [['Protected (kept)', '1: Seven']]);
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']);
const off = reviewOf(preview({delete: [1], backup: 'off'}));
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
});
@@ -69,8 +64,7 @@ test('the sentences explain archive, irreversibility per backup policy, re-creat
// Live test (#470): the archive reason is the saved pair, which a prompt with 0 negative variants has too.
assert.match(archive, /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the pair and its runs stay intact\./);
assert.doesNotMatch(archive, /variant/);
// #492: the archived shelf deletes them too.
assert.match(archive, /stay intact\. To delete them later or bring them back, press Show archived prompts, select them and press Delete… or Restore\.$/);
assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/);
assert.match(remove, /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\./);
assert.match(remove, /A later scan, a new image or a node run with the same text adds it again as a new prompt\.$/);
assert.equal(guarded, 'Protected prompts are tagged __protected__; remove the tag to delete them.');
@@ -97,10 +91,10 @@ test('a delete outcome counts each part, singular and plural, and names the back
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
backup: null}, null, reviewOf(preview())).toast,
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was not deleted.');
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was kept.');
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2,
backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast,
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were not deleted. 2 protected prompts were kept. The Library was reloaded.');
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were kept. 2 protected prompts were kept. The Library was reloaded.');
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
});
@@ -138,10 +132,9 @@ test('a failed preview reloads only after a restore; Restore outcomes have their
test('reviewOf keeps only well-formed ids and counts from an answer', async () => {
const {reviewOf} = await source;
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
not_found: 'x', image_links: 2, backup: 'maybe', not_archived: -3});
assert.deepEqual(review, {shelf: 'active', notArchived: 0, epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {},
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive', archiveOnly: []});
not_found: 'x', image_links: 2, backup: 'maybe'});
assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
imageLinks: 2, backup: 'on', disk: null});
});
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
@@ -155,173 +148,3 @@ test('the archived notice says why (the saved pair) and names negative variants
// An unknown count (an older server) never claims variants.
assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
});
// #482 (D160): prompts in a saved pair can be deleted permanently, with their own saved pairs.
const D1 = 'a'.repeat(64), D2 = 'b'.repeat(64);
const paired = (patch = {}) => preview({archive: [3, 4, 5], deletable: [3, 4], review_digests: {3: D1, 4: D2},
paired_counts: {pairs: 3, archived_pairs: 1, variants: 2, kept_negatives: 2}, paired_image_links: 4, ...patch});
test('reviewOf reads the deletable ids, their digests and the pair counts; the choice starts at Archive', async () => {
const {reviewOf, hasPairedChoice} = await source;
const review = reviewOf(paired({deletable: [3, 4, 99], review_digests: {3: D1, 4: 'short', 99: D1}}));
// Only reviewed archive ids with a well-formed digest can be deleted permanently.
assert.deepEqual(review.deletable, [3]);
assert.deepEqual(review.digests, {3: D1});
assert.deepEqual(review.pairedCounts, {pairs: 3, archivedPairs: 1, variants: 2, keptNegatives: 2});
assert.equal(review.pairedImageLinks, 4);
assert.equal(review.choice, 'archive');
assert.equal(hasPairedChoice(review), true);
assert.equal(hasPairedChoice(reviewOf(preview({archive: [5]}))), false);
});
test('Archive chosen keeps the #443 review; Delete permanently names the pairs, the kept negatives and archive-only', async () => {
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel, deleteBody} = await source;
const review = reviewOf(paired({delete: [1], image_links: 2}));
assert.deepEqual(reviewGrid(review).map(([term]) => term), ['Delete permanently', 'Archive', 'Image links removed', 'Backup']);
assert.equal(primaryLabel(review), 'Delete 1 and archive 3 prompts');
assert.deepEqual(deleteBody(review), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on'});
const permanent = withChoice(review, 'delete');
assert.notEqual(permanent, review);
assert.equal(review.choice, 'archive');
assert.deepEqual(reviewGrid(permanent), [
['Delete permanently', '3 prompts: 1 not in a saved pair, 2 in a saved pair'],
['Saved pairs removed', '3 saved pairs (2 with a negative prompt, 1 archived)'],
['Negatives kept', '2 negative prompts are kept'],
['Archive', '1 prompt used as another prompt\'s negative: hidden, restorable'],
['Image links removed', '6 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
assert.equal(reviewTitle(permanent), 'Delete or archive selected prompts');
assert.equal(primaryLabel(permanent), 'Delete 3 and archive 1 prompt');
assert.deepEqual(deleteBody(permanent), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on',
delete_paired_ids: [3, 4], paired_digests: {3: D1, 4: D2}});
});
test('edge counts: one prompt, no negative, nothing archive-only', async () => {
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel} = await source;
// Live prompt 7896: one pair with an explicitly empty negative.
const one = withChoice(reviewOf(preview({archive: [7], deletable: [7], review_digests: {7: D1},
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}, paired_image_links: 0})), 'delete');
assert.deepEqual(reviewGrid(one), [
['Delete permanently', '1 prompt in a saved pair'],
['Saved pairs removed', '1 saved pair'],
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
assert.equal(reviewTitle(one), 'Delete selected prompts');
assert.equal(primaryLabel(one), 'Delete 1 prompt');
});
test('Delete permanently sentences: what goes with the pairs, what stays, and how it comes back', async () => {
const {reviewOf, withChoice, reviewSentences} = await source;
const [pairs, remove, archive] = reviewSentences(withChoice(reviewOf(paired()), 'delete'));
assert.equal(pairs, 'Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
'and Activity, listed by their text.');
assert.match(remove, /the only way back is to restore the safety backup/);
assert.match(remove, /A pair node run with the same text saves a new pair\.$/);
assert.equal(archive, 'Prompts used as another prompt\'s negative are archived instead of deleted, so that ' +
'prompt\'s variant stays.');
const [, off] = reviewSentences(withChoice(reviewOf(paired({backup: 'off'})), 'delete'));
assert.match(off, /Deleted prompts cannot be brought back\./);
});
test('a short disk blocks only the permanent delete; the outcome names removed pairs', async () => {
const {reviewOf, withChoice, blockedReason, deleteOutcome} = await source;
const short = reviewOf(paired({disk: {sufficient: false, message: 'Not enough space. Nothing was changed.'}}));
assert.equal(blockedReason(short), null);
assert.equal(blockedReason(withChoice(short, 'delete')), 'Not enough space. Nothing was changed.');
const {toast} = deleteOutcome({success: true, deleted: 2, archived: 1, pairs_removed: 3, changed: 1}, null, short);
assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' +
'was not deleted.');
});
// #492: Delete… on the archived shelf: no choice, only the permanent delete; what cannot be deleted stays archived.
const shelfPreview = (patch = {}) => preview({deletable: [], review_digests: {}, paired_counts: {pairs: 0, archived_pairs: 0,
variants: 0, kept_negatives: 0}, paired_image_links: 0, not_archived: 0, ...patch});
test('the archived shelf review is the permanent delete, without the choice', async () => {
const {reviewOf, hasPairedChoice, withChoice, reviewTitle, primaryLabel, reviewGrid, deleteBody, blockedReason} = await source;
const review = reviewOf(shelfPreview({delete: [1], archive: [2, 3], deletable: [2], review_digests: {2: D1},
paired_counts: {pairs: 2, archived_pairs: 1, variants: 1, kept_negatives: 1}, paired_image_links: 1, image_links: 1,
not_archived: 1}), 'archived');
assert.equal(review.shelf, 'archived');
assert.equal(review.choice, 'delete');
assert.equal(hasPairedChoice(review), false);
assert.equal(withChoice(review, 'archive').choice, 'delete');
assert.equal(reviewTitle(review), 'Delete archived prompts');
assert.equal(primaryLabel(review), 'Delete 2 prompts');
assert.equal(blockedReason(review), null);
assert.deepEqual(reviewGrid(review), [
['Delete permanently', '2 prompts: 1 not in a saved pair, 1 in a saved pair'],
['Saved pairs removed', '2 saved pairs (1 with a negative prompt, 1 archived)'],
['Negatives kept', '1 negative prompt is kept'],
['Stays archived', "1 prompt used as another prompt's negative"],
['No longer archived (kept)', '1'],
['Image links removed', '2 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
// Only what it deletes is sent: the stays-archived id is left out.
assert.deepEqual(deleteBody(review), {archive_ids: [2], delete_ids: [1], expected_epoch: 'e', backup: 'on',
delete_paired_ids: [2], paired_digests: {2: D1}, shelf: 'archived'});
});
test('the archived shelf: unpaired only, nothing deletable, and its sentences', async () => {
const {reviewOf, reviewGrid, reviewSentences, primaryLabel, blockedReason, COPY} = await source;
const loose = reviewOf(shelfPreview({delete: [1]}), 'archived');
assert.deepEqual(reviewGrid(loose).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
assert.equal(reviewGrid(loose)[0][1], '1 prompt not in a saved pair');
assert.equal(primaryLabel(loose), 'Delete 1 prompt');
const [remove] = reviewSentences(loose);
assert.match(remove, /^Deleting removes each prompt's text/);
assert.doesNotMatch(reviewSentences(loose).join(' '), /saved pair/);
const stays = reviewOf(shelfPreview({archive: [3], protected: [4]}), 'archived');
assert.equal(blockedReason(stays), COPY.noneArchived);
assert.equal(COPY.noneArchived, 'None of the selected prompts can be deleted.');
const paired = reviewOf(shelfPreview({archive: [2, 3], deletable: [2], review_digests: {2: D1},
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}}), 'archived');
const sentences = reviewSentences(paired);
assert.match(sentences[0], /^Deleting a prompt in a saved pair also removes its saved pairs/);
assert.equal(sentences.at(-1), "Prompts used as another prompt's negative stay archived, so that prompt's variant stays.");
// The active shelf is unchanged: a preview without a shelf is #482's.
assert.equal(reviewOf(shelfPreview({archive: [2], deletable: [2], review_digests: {2: D1}})).choice, 'archive');
});
test('the outcome names prompts restored since the review', async () => {
const {deleteOutcome} = await source;
const outcome = deleteOutcome({success: true, deleted: 1, archived: 0, not_archived: 2, changed: 0}, null, {protected: []});
assert.equal(outcome.toast, 'Deleted 1 prompt. 2 were restored since the review and kept.');
const none = deleteOutcome({success: true, deleted: 0, archived: 0, not_archived: 1, changed: 1}, null,
{protected: [], shelf: 'archived'});
assert.equal(none.toast, 'Nothing was deleted. 1 was restored since the review and kept. 1 changed since the review and ' +
'was not deleted. The Library was reloaded.');
});
// #512 (live test of #505/#507/#509): the review names a single prompt, and a negative selected without its positive.
test('a review from a prompt\'s own page names that prompt', async () => {
const {reviewOf, reviewTitle, blockedReason} = await source;
const single = review => ({...review, single: true});
assert.equal(reviewTitle(single(reviewOf(preview({delete: [1]})))), 'Delete this prompt');
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2]})))), 'Archive this prompt');
const kept = single(reviewOf(preview({protected: [3]})));
assert.equal(reviewTitle(kept), 'Delete this prompt');
assert.equal(blockedReason(kept), 'This prompt cannot be deleted or archived.');
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2], deletable: [2], review_digests: {2: 'c'.repeat(64)}}),
'archived'))), 'Delete this prompt');
});
test('a negative used by another prompt is named as such when it is archived', async () => {
const {reviewOf, reviewGrid, reviewSentences} = await source;
const negative = reviewOf(preview({archive: [5], deletable: []}));
assert.deepEqual(reviewGrid(negative), [['Archive', "1 prompt used as another prompt's negative: hidden, restorable"]]);
assert.deepEqual(reviewSentences(negative), ["Prompts used as another prompt's negative are archived instead of deleted, so " +
"that prompt's variant stays. To bring them back, press Show archived prompts, select them and press Restore."]);
const mixed = reviewOf(preview({archive: [2, 5], deletable: [2], review_digests: {2: 'c'.repeat(64)}}));
assert.deepEqual(reviewGrid(mixed)[0], ['Archive', "2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable"]);
// A paired prompt whose review digest is missing is not called another prompt's negative (review M1).
const noDigest = reviewOf(preview({archive: [2], deletable: [2], review_digests: {}}));
assert.deepEqual(reviewGrid(noDigest), [['Archive', '1 prompt in a saved pair: hidden, restorable']]);
const sentences = reviewSentences(mixed);
assert.match(sentences[0], /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted/);
assert.match(sentences[1], /^Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant stays\.$/);
});
+1 -101
View File
@@ -157,27 +157,6 @@ test('Viewer.js is built only on a loaded image and an unbuilt viewer is release
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
test('a pending Viewer whose element left the stage is still destroyed, without an error', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
const instances = [];
// Viewer.js's destroy() ends with `this.viewer.parentNode.removeChild(this.viewer)`, and moving on empties the stage.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = true; this.viewer = document.createElement('div'); instances.push(this); }
destroy() { if (!this.viewer.parent) throw new TypeError('null is not an object'); this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
const [loading] = instances, removed = [];
loading.node.removeAttribute = key => removed.push(key);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
assert.deepEqual(removed, ['src'], 'and its image is released');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
@@ -221,9 +200,7 @@ test('viewer renders the concept shell and toolbar order', async () => {
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
const [header,layout] = dialog.children;
assert.equal(header.className,'dialog-header');
const [heading,keys,mode,close] = header.children, [title,subtitle] = heading.children;
// #532 D170: ⌘ before the view switch opens the shortcuts.
assert.deepEqual([keys.tagName,keys.id,keys.className,keys.attributes['aria-label'],keys.title],['button','viewer-keys','icon-btn','Keyboard shortcuts','Keyboard shortcuts']);
const [heading,mode,close] = header.children, [title,subtitle] = heading.children;
// #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default).
assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']);
mode.fire('click');
@@ -256,83 +233,6 @@ test('viewer renders the concept shell and toolbar order', async () => {
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('⌘ opens the shortcuts on top of the viewer and closing them leaves it open (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, [keys] = f.walk(dialog,n => n.id === 'viewer-keys');
const position = () => f.walk(dialog,n => n.attributes.role === 'status')[0].textContent;
const overlays = () => f.walk(dialog,n => n.id === 'viewer-keys-dialog');
assert.equal(overlays().length,0,'built on demand');
assert.match(position(),/^1 of 2/);
f.doc.fire('keydown',{key:'ArrowRight',target:f.walk(dialog,n => n.id === 'viewer-close')[0]});
assert.match(position(),/^2 of 2/,'the arrows work with focus on ×');
keys.fire('click');
const [overlay] = overlays();
assert.equal(overlay.open,true);
assert.equal(overlay.attributes['aria-labelledby'],'viewer-keys-title');
assert.deepEqual(f.walk(overlay,n => n.tagName === 'kbd').map(n => n.textContent),['←','→','Space','+','-','0','R','H','F','I','?','Esc','D']);
f.doc.fire('keydown',{key:'ArrowLeft',target:keys});
assert.match(position(),/^2 of 2/,'the arrows wait for the overlay');
f.walk(overlay,n => n.attributes['aria-label'] === 'Close keyboard shortcuts')[0].fire('click');
assert.equal(overlays().length,0);
assert.equal(dialog.open,true,'the viewer stays open');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('hotkeys click the toolbar\'s own controls, so an unavailable control does nothing (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')], deletes = [];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onDelete:(entry,button) => deletes.push([entry,button])});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, by = (test) => f.walk(dialog,test)[0];
const stage = by(n => n.id === 'viewer-stage'), mode = by(n => n.id === 'viewer-mode'), remove = by(n => n.textContent === 'Delete…');
const press = (key,init = {}) => { let prevented = false; f.doc.fire('keydown',{key,target:stage,preventDefault(){prevented = true;},...init});return prevented; };
// No media is ready: every transform is aria-disabled, so its key is swallowed by the same check as a click.
for (const key of ['+','=','-','0','r','H']) assert.equal(press(key),true,key);
// Modifiers, repeats and composition leave every key alone; Shift does not.
for (const init of [{ctrlKey:true},{metaKey:true},{altKey:true},{repeat:true},{isComposing:true}]) assert.equal(press('f',init),false);
assert.equal(press('+',{repeat:true}),true,'zoom may repeat');
assert.equal(press('Unbound'),false);
// F clicks the switch, and does nothing while the switch is hidden.
assert.equal(mode.attributes['aria-label'],'Full screen view');
press('F',{shiftKey:true});assert.equal(mode.attributes['aria-label'],'Centered view');
mode.layoutHidden = true;assert.equal(press('f'),false);assert.equal(mode.attributes['aria-label'],'Centered view');mode.layoutHidden = false;
// I needs the toggle: it is there in full screen and gone when Centered.
const details = by(n => n.id === 'viewer-details');
press('i');assert.equal(details.hidden,true);press('i');assert.equal(details.hidden,false);
press('f');assert.equal(press('i'),false,'no toggle when Centered');
// Delete only opens the page's review, for this file.
press('Delete');assert.equal(deletes.length,1);assert.equal(deletes[0][1],remove);
press('Delete',{repeat:true});assert.equal(deletes.length,1);
// D goes through the link's own check while the file cannot be downloaded.
controller.publish({loading:false,error:false,record:null,refreshRequired:true,message:'Refresh required.'});
const download = by(n => n.attributes['aria-label'] === 'Download original');
let blocked = false;download.fire('click',{preventDefault(){blocked = true;}});assert.equal(blocked,true);
assert.equal(press('d'),true);
// Space and ? on the stage; a target that keeps its keys leaves them alone.
assert.equal(press(' '),true);
assert.equal(press('?'),true);
const overlay = by(n => n.id === 'viewer-keys-dialog');assert.equal(overlay.open,true);
assert.equal(press('r'),false,'off under the overlay');
overlay.close();
assert.equal(press('r',{target:new f.Node('body')}),false,'a target outside the viewer that is not the page');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
const g = mountedDom(), scope = createScope();
const plain = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
try {
plain.open({items,selectedKey:'c'.repeat(64)});
const stage = g.walk(g.container,n => n.id === 'viewer-stage')[0];
let prevented = false;g.doc.fire('keydown',{key:'Delete',target:stage,preventDefault(){prevented = true;}});
assert.equal(prevented,false,'no Delete… in this viewer, so no Delete key');
g.doc.fire('keydown',{key:'?',target:stage});
assert.ok(!g.walk(g.container,n => n.tagName === 'kbd').some(n => n.textContent === 'Delete'),'and none listed');
} finally { plain.dispose(); scope.dispose(); g.restore(); }
});
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
+21 -99
View File
@@ -128,7 +128,9 @@ test('selecting a card loads detail and option labels, first workable preselecte
['Sampler 9 · No positive / Soft negative', true],
['Sampler 12 · Dunes / Soft negative', false],
['Sampler 28 · Dunes / Harsh negative', false],
['Leave unresolved', false],
]);
assert.equal(c.state.options.at(-1).value, 'unresolved');
assert.equal(c.state.choice, HASH_A);
assert.deepEqual(c.state.prompts, { positive: { status: 'resolved', text: 'golden dunes' }, negative: { status: 'resolved', text: 'blur' } });
c.choose(HASH_C);
@@ -136,17 +138,16 @@ test('selecting a card loads detail and option labels, first workable preselecte
c.choose(HASH_B);
assert.equal(c.state.prompts.negative.text, 'noise');
c.choose('unresolved');
assert.equal(c.state.choice, HASH_B, 'Leave unresolved is its own action');
assert.equal(c.state.prompts.positive.status, 'ambiguous');
await c.select('two');
await settle();
assert.equal(c.state.selectedId, 'two');
assert.deepEqual(c.state.options, []);
assert.equal(c.state.choice, null);
assert.equal(c.state.unreadable, null, 'a limited file keeps its own warning');
assert.deepEqual(c.state.options.map(option => option.label), ['Leave unresolved']);
assert.equal(c.state.choice, 'unresolved');
assert.match(c.state.warning, /too many sampler paths/i);
});
test('apply sends the selected candidate hash with a fresh capture and the next item opens', async () => {
test('apply sends the selected candidate hash or unresolved with a fresh capture', async () => {
const { c, h } = await loaded();
c.choose(HASH_B);
const done = await c.apply();
@@ -156,105 +157,31 @@ test('apply sends the selected candidate hash with a fresh capture and the next
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
assert.deepEqual(decision.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
dependency_fingerprint: PRINT, choice: HASH_B });
assert.equal(c.state.phase, 'idle');
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.phase, 'done');
assert.equal(c.state.total, 1);
assert.equal(c.state.toast, 'Association accepted.');
assert.equal(c.state.decisions, 1);
// The server releases a used capture itself.
assert.equal(h.of('DELETE', CAPTURE).length, 0);
});
test('leave unresolved is its own action', async () => {
const { c, h } = await loaded();
assert.equal(await c.leaveUnresolved(), true);
const [unresolved] = h.of('POST', `${ITEMS}/one/decision`);
const again = await loaded();
again.c.choose('unresolved');
assert.equal(await again.c.apply(), true);
const [unresolved] = again.h.of('POST', `${ITEMS}/one/decision`);
assert.equal(unresolved.body.choice, 'unresolved');
assert.equal(unresolved.body.capture_token, 'capture-1');
assert.equal(c.state.toast, 'Left unresolved.');
assert.equal(c.state.selectedId, 'two');
assert.equal(again.c.state.toast, 'Left unresolved.');
});
const unreadable = () => detailOf(QUEUE[0], { candidates: [
{ ...candidate(HASH_A, '9', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
{ ...candidate(HASH_B, '12', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
{ ...candidate(HASH_C, '14', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported node' },
] });
test('nothing selectable: the reasons are summed, there is no choice and apply sends nothing', async () => {
const { c, h } = await loaded({ [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
assert.equal(c.state.choice, null);
assert.equal(c.state.prompts, null);
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
['Sampler 9 · No positive / No negative (Unsupported latent ancestry)', true],
['Sampler 12 · No positive / No negative (Unsupported latent ancestry)', true],
['Sampler 14 · No positive / No negative (Unsupported node)', true],
]);
assert.deepEqual(c.state.unreadable, { count: 3, reasons: [{ reason: 'Unsupported latent ancestry', count: 2 }, { reason: 'Unsupported node', count: 1 }] });
assert.equal(await c.apply(), false);
assert.equal(h.of('POST', '/').length, 0);
});
const linkRoute = (body = {}) => ({ [`POST ${ITEMS}/one/link`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
item: { ...QUEUE[0], state: 'selected', revision: 1 }, pair: { id: call.body.pair_id ?? 7, image_id: 3, status: 'added' }, ...body }) });
test('link posts the chosen prompt and recipe with a fresh capture, then the next item opens', async () => {
const { c, h } = await loaded({ ...linkRoute(), [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
const [link] = h.of('POST', `${ITEMS}/one/link`);
assert.deepEqual(link.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
dependency_fingerprint: PRINT, positive_id: 4, pair_id: 9 });
assert.equal(c.state.toast, 'Linked to Harbor.');
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.decisions, 1);
});
test('a refused link keeps the item, releases the capture and names the reason', async () => {
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => fail(409, 'association-conflict', 'This image is already linked') });
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: null, title: 'Harbor' }), false);
assert.equal(c.state.selectedId, 'one');
assert.equal(c.state.warning, 'This image is already linked');
assert.equal(c.state.decisions, 0);
assert.equal(h.of('DELETE', CAPTURE).length, 1);
});
test('an unanswered link resends the same body once; another choice takes a new capture', async () => {
let answers = 0;
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => {
answers += 1;
return answers === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, total: 1 });
} });
assert.equal(await c.link({ itemId: 'two', positiveId: 4, pairId: 9, title: 'Harbor' }), false, 'not the open item');
assert.equal(h.of('POST', OBSERVE).length, 0);
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), false);
assert.match(c.state.warning, /Link image again/);
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
const [first, second] = h.of('POST', `${ITEMS}/one/link`);
assert.deepEqual(first.body, second.body);
assert.equal(h.of('POST', OBSERVE).length, 1);
});
test('the last decision empties the page and a later page still comes back from the re-read', async () => {
let queue = [QUEUE[0]];
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
queue = [];
assert.equal(await c.leaveUnresolved(), true);
await settle();
assert.equal(c.state.selectedId, null);
assert.deepEqual(c.state.items, []);
assert.equal(c.state.warning, '');
});
test('an unavailable file can only be left unresolved, without a capture or a link', async () => {
test('an unavailable file can only be left unresolved, without a capture', async () => {
const gone = item('one', { target: null, unavailable: true, url: null });
const { c, h } = await loaded({
[`GET ${ITEMS}`]: () => response(200, page([gone])),
[`GET ${ITEMS}/one`]: () => response(200, detailOf(gone)),
});
assert.ok(c.state.options.every(option => option.disabled));
assert.equal(c.state.choice, null);
assert.ok(c.state.options.slice(0, -1).every(option => option.disabled));
assert.equal(c.state.choice, 'unresolved');
assert.match(c.state.warning, /not under a current image source/i);
assert.equal(await c.link({ itemId: 'one', positiveId: 1, pairId: null, title: 'x' }), false);
assert.equal(await c.leaveUnresolved(), true);
assert.equal(await c.apply(), true);
assert.equal(h.of('POST', OBSERVE).length, 0);
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
assert.equal(decision.body.capture_token, null);
@@ -348,14 +275,11 @@ test('a review-conflict on a still pending item reads its detail again', async (
assert.equal(h.of('POST', `${ITEMS}/one/decision`).at(-1).body.expected_revision, 1);
});
test('leave for later writes nothing, opens the next item and wraps', async () => {
test('leave for later writes nothing', async () => {
const { c, h } = await loaded();
await c.leave();
assert.equal(c.state.selectedId, 'two');
c.leave();
assert.equal(c.state.phase, 'done');
assert.equal(c.state.toast, null);
assert.equal(c.state.decisions, 0);
await c.leave();
assert.equal(c.state.selectedId, 'one');
assert.equal(h.of('POST', '/').length, 0);
assert.equal(h.of('DELETE', '/').length, 0);
});
@@ -857,9 +781,7 @@ test('M5: an unanswered decision that landed finishes as recorded, not as decide
queue = [QUEUE[1]]; // the server did record it: its change arrives
await c.refresh();
await settle();
assert.equal(c.state.phase, 'idle');
assert.equal(c.state.decisions, 1);
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.phase, 'done');
assert.equal(c.state.toast, 'Association accepted.');
assert.doesNotMatch(c.state.warning, /decided elsewhere/);
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the server used the capture');
+16 -46
View File
@@ -17,7 +17,7 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
const tags = resolveRoute('#/tags');
assert.equal(tags.label, 'Tags & categories');
assert.equal(routeTitle(tags), 'Tags');
assert.equal(tags.href, '#/tags', 'Overview opens the native page (#525)');
assert.equal(tags.href, '/prompt_manager/admin#/tags', 'Overview keeps the existing tag manager card');
for (const id of ['images', 'library', 'inspect', 'overview', 'system']) {
const route = resolveRoute(`#/${id}`);
assert.equal(routeTitle(route), route.label);
@@ -48,6 +48,7 @@ test('system resolves its sections; #/overview stays the production hub', async
// Q12: Overview's System card opens the native System Overview, with no legacy href or action.
const card = routes.find(route => route.id === 'system');
assert.equal(card.href, '#/system/overview');
assert.equal(card.native, true);
assert.equal(card.action, 'Open System');
});
@@ -76,28 +77,9 @@ test('inspect is the native Inspect a file page with the concept description', a
// The concept's heading description (app.js renderMetadata 743).
assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.');
assert.equal(isHandoff(inspect), false);
// Overview's card opens the native page (#525); the legacy metadata page stays at its URL.
assert.equal(inspect.href, '#/inspect');
assert.equal(inspect.action, 'Open Inspect a file');
});
test('every Overview card opens its own native page, never a legacy one (#525)', async () => {
const { resolveRoute, routes } = await source;
const cards = routes.filter(route => route.href);
assert.deepEqual(cards.map(card => card.id), ['images', 'library', 'tags', 'inspect', 'system']);
for (const card of cards) {
assert.match(card.href, /^#\//, card.id);
assert.equal(resolveRoute(card.href).id, card.id);
assert.match(card.action, /^Open /, card.id);
assert.doesNotMatch(card.action, /existing/, card.id);
}
});
test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => {
const { LEGACY_PAGES } = await source;
assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags',
'/prompt_manager/gallery', '/prompt_manager/gallery.html']);
for (const page of LEGACY_PAGES) assert.ok(page.label);
// Overview keeps its card for the legacy metadata page, which stays working.
assert.equal(inspect.href, '/prompt_manager/gallery.html');
assert.equal(inspect.action, 'Open existing metadata inspector');
});
test('a job of the Activity page is its own deep link', async () => {
@@ -123,29 +105,17 @@ test('a job of the Activity page is its own deep link', async () => {
dispose();
});
test('Overview cards show the count of the page they name', async () => {
const { summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES } = await source;
assert.deepEqual(OVERVIEW_COUNT_ROUTES, ['library', 'images', 'tags']);
const counts = new Map([['library', 64], ['images', 122], ['tags', 306], ['favorites', 8]]);
assert.deepEqual(summarizeOverviewCounts(counts), {
cards: [['Prompts', 64], ['Images', 122], ['Tags', 306]], pending: false, failed: false, empty: false,
});
});
test('Overview tells loading, failed and really empty counts apart', async () => {
const { summarizeOverviewCounts } = await source;
const nothing = summarizeOverviewCounts(new Map());
assert.deepEqual([nothing.pending, nothing.failed, nothing.empty], [true, false, false]);
assert.deepEqual(nothing.cards, [['Prompts', null], ['Images', null], ['Tags', null]]);
// A slow Images read does not hold back the cards already read.
const partial = summarizeOverviewCounts(new Map([['library', 0], ['tags', 0]]));
assert.deepEqual(partial.cards, [['Prompts', 0], ['Images', null], ['Tags', 0]]);
assert.deepEqual([partial.pending, partial.failed, partial.empty], [true, false, false]);
const failed = summarizeOverviewCounts(new Map([['library', 2], ['images', null], ['tags', 3]]));
assert.deepEqual([failed.pending, failed.failed, failed.empty], [false, true, false]);
assert.deepEqual(failed.cards[1], ['Images', null]);
const empty = summarizeOverviewCounts(new Map([['library', 0], ['images', 0], ['tags', 0]]));
assert.deepEqual([empty.pending, empty.failed, empty.empty], [false, false, true]);
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
['Prompts', 0], ['Images', 12], ['Tags', 3],
]);
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
{total_prompts:'2', total_images:0, total_tags:0},
{total_prompts:Infinity, total_images:0, total_tags:0},
{total_prompts:0, total_images:1.5, total_tags:0}]) {
assert.throws(() => summarizeStats(stats), /statistics/i);
}
});
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
-16
View File
@@ -174,19 +174,3 @@ test('D158: "Save prompts from other workflows" drafts save_foreign_runs, disabl
assert.deepEqual(foreignRunsState({ value: true, monitoring: false, stopping: true }),
{ disabled: false, checked: true, note: FOREIGN_RUNS.off });
});
test('a failed notice write still dismisses for this page and tells watchers (#527)', async () => {
const { noticeDismissed, dismissNotice } = await sourcesModule;
const denied = () => { throw new Error('SecurityError'); };
assert.equal(noticeDismissed(denied), false);
assert.equal(dismissNotice(denied), false);
assert.equal(noticeDismissed(denied), true);
});
test('sourceCount reads the effective sources, null when the answer does not say (#527)', async () => {
const { sourceCount } = await import('../../web/js/workspace/older-images-notice.mjs');
assert.equal(sourceCount({ sources: { effective: [{}, {}] } }), 2);
assert.equal(sourceCount({ sources: { effective: [] } }), 0);
assert.equal(sourceCount({ sources: {} }), null);
assert.equal(sourceCount(null), null);
});
+6 -56
View File
@@ -13,11 +13,10 @@ class FakeToast extends EventTarget {
matches(selector) { assert.equal(selector, ':hover, :focus-within'); return this.held; }
}
function install(dialogs = []) {
function install() {
const toast = new FakeToast();
const previous = global.document;
global.document = {
querySelectorAll: selector => { assert.equal(selector, 'dialog'); return dialogs; },
getElementById: id => id === 'workspace-toast' ? toast : null,
createTextNode: text => ({ textContent: text }),
createElement: tag => ({ tag, href: '', textContent: '' }),
@@ -25,18 +24,6 @@ function install(dialogs = []) {
return { toast, restore: () => { global.document = previous; } };
}
// First: the module remembers a toast it has shown.
test('a page without the toast shows nothing and does not fail', async () => {
const { showWorkspaceToast } = await source;
const previous = global.document;
global.document = { getElementById: () => null };
try {
assert.doesNotThrow(() => showWorkspaceToast('Nothing.'));
} finally {
global.document = previous;
}
});
test('a plain toast clears after 6.5 s and a linked one after 20 s', async t => {
const { showWorkspaceToast, TOAST_MS, LINK_TOAST_MS } = await source;
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -97,50 +84,13 @@ test('a toast under the pointer or focus waits until they leave', async t => {
}
});
class FakeHost extends EventTarget {
constructor(modal = false, localName = 'dialog') { super(); this.modal = modal; this.localName = localName; this.nodes = []; }
matches(selector) { assert.equal(selector, ':modal'); return this.modal; }
append(node) { node.parentNode?.nodes.splice(node.parentNode.nodes.indexOf(node), 1); node.parentNode = this; this.nodes.push(node); }
}
test('a toast shows inside the topmost open modal and goes home when it closes (#533)', async () => {
test('a page without the toast shows nothing and does not fail', async () => {
const { showWorkspaceToast } = await source;
const closed = new FakeHost(false), lower = new FakeHost(true), top = new FakeHost(true);
const dialogs = [closed, lower, top];
const { toast, restore } = install(dialogs);
const home = new FakeHost(false, 'body');home.append(toast);
const previous = global.document;
global.document = { getElementById: () => null };
try {
showWorkspaceToast('Opened the folder on this computer.');
assert.equal(toast.parentNode, top);
// The upper modal closes: the toast stays visible in the one under it.
top.modal = false;top.dispatchEvent(new Event('close'));
assert.equal(toast.parentNode, lower);
lower.modal = false;lower.dispatchEvent(new Event('close'));
assert.equal(toast.parentNode, home);
showWorkspaceToast('Next.');
assert.equal(toast.parentNode, home);
assert.doesNotThrow(() => showWorkspaceToast('Nothing.'));
} finally {
restore();
}
});
test('a toast removed along with its modal still shows the next message (#533)', async () => {
const { showWorkspaceToast } = await source;
const modal = new FakeHost(true);
const { toast, restore } = install([modal]);
const home = new FakeHost(false, 'body');home.append(toast);
try {
showWorkspaceToast('Inside.');
assert.equal(toast.parentNode, modal);
modal.modal = false;
const byId = document.getElementById;
document.getElementById = () => null; // the dialog left the page with the toast in it
showWorkspaceToast('After.');
assert.equal(toast.parentNode, home);
assert.equal(toast.textContent, 'After.');
document.getElementById = byId;
} finally {
restore();
global.document = previous;
}
});

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