Compare commits

..
Author SHA1 Message Date
Vito Sansevero bebf4c2986 fix(security): send a generic LoRA scan error to the browser
An unexpected exception's text could reach the page in the LoRA scan's
error frame, including a path or a model name. Those failures now send a
fixed message and keep the detail in the server log. Expected refusals keep
their wording, and an OSError still shows its reason and the file's basename.

Refs #552
2026-10-30 23:04:52 -07:00
Vito d88cf9e7c6 fix(quality): sweep the Code Quality standard findings (#558)
* fix(quality): preserve cursor iteration and option validation

Keep prefetched rows on ManagedCursor so partial loops and explicit fetches share one position, and stop existing iterators after close or retirement. Preserve chunked reads and native SQLite result lifetime on reexecution and errors.

Catch OptionError before ValueError so legacy auto-tag requests receive the actual validation error. Remove the overwritten ViewerJS title option.

Regression tests failed before the fixes for lost rows, reads after close/retirement, and misleading option errors. Focused Python checks pass; cursor checks also pass on Python 3.13 and 3.14. Refs #321.

* fix(quality): make the test doubles legal raises and hashes

The fake queue no longer raises an attribute that starts as None. The
oversized kind records hashes instead of raising from __hash__, which
also catches a hash that production code swallows. Hostile.__bool__
raises the TypeError a truth test may raise.

* fix(quality): drop the extra saveCoverage argument

* refactor(quality): remove assignments that are overwritten before use

* refactor(quality): remove the remaining overwritten test assignments

* refactor(quality): drop imports repeated inside functions

* refactor(quality): give duplicate identity keys one shape

* refactor(quality): name the unused job event loop variable

* refactor(quality): release model gates in finally, not except BaseException

* refactor(quality): end the job runners with an explicit return

* refactor(quality): use the precise unittest asserts

* refactor(quality): import unittest.mock one way in the tests

* fix(quality): say why each empty except is safe, log the broad ones

* refactor(quality): stop binding values the tests never read

* refactor(quality): remove the unused preview aliases and tag page query

* refactor(quality): remove re-exports nothing imports

* refactor(quality): parenthesize deliberate string joins in lists

* refactor(quality): remove unused JavaScript variables and imports

* refactor(quality): use the precise assert in the model status test
2026-10-31 05:43:48 +00:00
Vito df70b5dfd6 docs: new README as the product page, and release bump to 3.4.0 (#545)
* docs: new README as the product page, keep the old one as README-legacy

Rename README.md to README-legacy.md with a short note saying what it is,
and write a new README: hero, visual feature tour, all six nodes, then
install, data locations, upgrade notes, legacy pages, limits and testing.
Images are compressed WebP in assets/readme. Point the TESTING.md link at
the section's new home.

* docs: retake README screenshots, add shortcuts, pinned bar and trash limits

* chore: release 3.4.0
2026-10-31 02:57:16 +00:00
Vito Sansevero 7e1847955b fix(viewer): destroy a pending viewer whose element left the stage
rebuild() empties the stage while a view is pending, and the deferred destroy then failed on the viewer's null parent: a page error, and the image kept its src. The destroy now finishes and the src is released.
2026-10-31 02:01:06 +00:00
Vito Sansevero ecb6b09813 test(viewer): wait for the viewer's first fit before reading it
The keys spec read the fit as soon as Zoom enabled, a frame before the viewer fits the image to the stage. Under load it then compared 873.6px with 901.2px. Failed 3 of 3 under --cpus=2 on WebKit, on the combined head and on #534's own head; with the wait the spec passes 96 of 96.
2026-10-31 02:01:06 +00:00
Vito Sansevero cdb791d4a9 fix(nodes): Pair node fills the prompt's empty category and tags (#551)
Closes #546
2026-10-31 02:01:06 +00:00
Vito Sansevero f924d7195d fix(workspace): Overview cards show the sidebar's counts (#549)
Closes #547
2026-10-31 02:01:06 +00:00
Vito Sansevero 3789d85e58 docs(testing): record D190, the trash review primary that cannot act 2026-10-31 02:01:06 +00:00
Vito Sansevero fb6452934f test(trash): classify the Needs review link route and prove it refuses a trashed file
POST /review/items/{id}/link needs a capture of the item's bytes. A capture is refused for a path in a trash folder, and a capture taken earlier stops working when its file moves to a trash folder or its folder becomes a link to one, so no code change is needed. The decision route takes the same path, so it moves to the same audit group.
2026-10-31 02:01:06 +00:00
Vito Sansevero 395dc74cbe fix(trash): trash on an output folder that is a mount point (#543) (#544)
Closes #543
2026-10-31 02:01:06 +00:00
Vito Sansevero 2f061904a2 test(workspace): wait for the delete job before its end toast in the sticky chrome spec 2026-10-31 02:01:06 +00:00
Vito Sansevero f80e67c812 feat(workspace): selection bars stay in view, Back to top, side panels stay with you (#531) (#541)
Closes #531
2026-10-31 02:01:06 +00:00
Vito Sansevero ab6fee3a82 fix(viewer): arrow keys navigate from anywhere; a shortcuts overlay (#532) (#534)
Closes #532
2026-10-31 02:01:06 +00:00
Vito Sansevero f53a43152d feat(review): link an image to a library prompt from Needs review (#536) (#540)
Closes #536
2026-10-31 02:01:06 +00:00
Vito Sansevero fe63f01214 fix(send): name ComfyUI windows by their tab and explain why a send is blocked (#539)
Closes #535
2026-10-31 02:01:06 +00:00
Vito Sansevero aab0fedc10 fix(workspace): level two-column cards, window the Reusable tags list, full-width System (#530) (#538)
Closes #530
2026-10-31 02:01:06 +00:00
Vito Sansevero 085e45e475 fix(images): show every Show in folder outcome above the viewer and report opener failures (#537)
Closes #533
2026-10-31 02:01:06 +00:00
Vito Sansevero 89572e03c6 test(images): wait for the delete and return jobs, not 5 s (#554)
Closes #553
2026-10-31 02:01:06 +00:00
Vito ae3e191990 feat(workspace): show the one-time older-images notice in the shell (#529)
* feat(workspace): show the one-time older-images notice in the shell

After the switch everyone lands on Library, so someone who has not dismissed
the notice might never be prompted. It now shows above every workspace page
until dismissed, sharing pm_update_notice_dismissed with System > Sources and
the admin modal. Browser specs start with it dismissed.

Closes #527

* test(workspace): turn the older-images notice on where specs expect it

Two System specs read the Sources notice from empty storage, and the new
spec read a job list earlier specs had filled.
2026-10-30 22:06:17 +00:00
Vito cd284432f5 feat(cutover): every entry point opens the workspace; legacy pages stay reachable (#528)
* feat(cutover): every entry point opens the workspace; legacy pages stay reachable

The top-bar PM button, the nodes' Web UI buttons and /prompt_manager/web now
open the workspace Library. Overview's cards open their native pages. The legacy
pages stay at their URLs, with no route changed.

Classic Web UI no longer navigates the canvas away when a popup is blocked; it
shows a notification, as the focused node does.

Closes #525

* feat(cutover): link the legacy pages from System and when the workspace fails to load

System's Overview section ends with one small Legacy pages line (D165). If a
workspace module fails to load or throws while starting, the page shows a static
message with the same links instead of an empty shell.

Part of #525

* test(cutover): prove the Pair node's Web UI on the real host

Part of #525

* test(cutover): count the new real-host cases in the stock matrix pin

The PM button test and three renderer-and-reload tests, in 2 projects, take the
stock matrix from 110 to 118.

Part of #525
2026-10-30 22:06:12 +00:00
Vito a6d6129ec6 fix(library): keep the prompt page heading on one row; no history subtitle on a deleted prompt's page (#524) (#526)
* fix(library): keep the prompt page heading on one row; no history subtitle on a deleted prompt's page (#524)

From 951px the heading actions keep one row and a long title clamps to two lines beside them (D164). A deleted prompt's page hides the Prompt history description.

* fix(library): drop the CSS file name from a comment the consumer check reads (#524)
2026-10-30 12:16:55 +00:00
238 changed files with 7945 additions and 2127 deletions
+39
View File
@@ -29,6 +29,9 @@ env:
# 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.
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
@@ -104,3 +107,39 @@ jobs:
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
+1196
View File
File diff suppressed because it is too large Load Diff
+139 -1084
View File
File diff suppressed because it is too large Load Diff
+194 -33
View File
@@ -763,7 +763,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 local-only
`Prompt evidence` line, Download original and, for a listed file, D38's
"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,6 +1402,13 @@ 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:
@@ -1566,20 +1573,27 @@ Recorded deviations on this page (spec §8):
| Result | Toast |
|---|---|
| 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." |
| 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." |
| 429 `busy` | "The folder is already opening." |
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
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
**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
`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
@@ -1596,7 +1610,9 @@ 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. Those openers are argv lists resolved only on a fixed
`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
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`,
@@ -1604,12 +1620,11 @@ 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.
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.
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.
- **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
@@ -2489,7 +2504,28 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
(`_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`.
`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
@@ -2641,8 +2677,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; Overview's legacy card ("Open
existing prompt library") and the sidebar still link `/prompt_manager/admin`
"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)
(`tests/browser/workspace.spec.mjs`). `library comparison detects
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
@@ -2888,6 +2924,14 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library
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 ·
@@ -2928,6 +2972,29 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete
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
@@ -3117,8 +3184,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 reachable from Overview's "Open existing metadata
inspector" card (`/prompt_manager/gallery.html`). The page is the concept's `#metadata`
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`
(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
@@ -3396,15 +3463,20 @@ 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, Sampler context,
the chosen path's prompt blocks, Leave for later and primary Apply association). The
`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
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, preselects the first workable one and ends with "Leave unresolved"
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
unusable ones with the server's reason and preselects the first workable one
(rulings R2/R3). Apply
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
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
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
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
@@ -3419,6 +3491,14 @@ 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
@@ -3992,6 +4072,23 @@ 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
@@ -4235,6 +4332,24 @@ 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
@@ -4340,6 +4455,15 @@ 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
@@ -5617,6 +5741,23 @@ 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
@@ -6656,7 +6797,7 @@ checkout or point this harness at a live ComfyUI checkout.
The package-qualified config import also corrects #211: the configured database
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
[database path compatibility and safe backup sequence](README.md#database-path-compatibility-211)
[database path compatibility and safe backup sequence](README-legacy.md#database-path-compatibility-211)
before restarting an existing deployment.
The ledger records only new dequeued jobs and observed outcomes. Existing
@@ -7029,8 +7170,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 reachable from Overview's
"Open existing tag manager" card for its own page; tag search and sort, the untagged
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
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
@@ -7152,3 +7293,23 @@ 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`).
+3
View File
@@ -120,6 +120,7 @@ except Exception as e:
logger = get_logger("prompt_manager.init")
logger.error(f"Failed to register API routes: {e}")
except Exception:
# logging failed too; a report must never stop the node loading
pass
# Shared database, set up with the monitoring system below. Stays None when that
@@ -411,6 +412,7 @@ except Exception as e:
f"Failed to register the exit signal: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
try:
@@ -428,6 +430,7 @@ except Exception as e:
f"Failed to register the background work teardown: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+1
View File
@@ -590,6 +590,7 @@ class _Move:
try:
os.rmdir(self.plan.target_dir)
except OSError:
# not empty or already gone: the undo leaves it alone
pass
+62 -22
View File
@@ -35,38 +35,51 @@ from __future__ import annotations
import contextlib
import logging
import operator
import sys
import threading
import time
import weakref
from typing import Iterable, Iterator, List, Optional
from collections import deque
from typing import Any, Callable, Iterable, Iterator, List, Optional
import sqlite3
CLOSED_MESSAGE = "Cannot operate on a closed database."
# Rows fetched per step when a cursor is iterated: the connection is marked
# in use once per chunk instead of once per row (iteration costs about what
# a plain cursor costs). A loop that stops early leaves up to this many rows
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
# a plain cursor costs). Prefetched rows belong to the cursor so iteration
# and explicit fetches always advance the same position.
ITER_CHUNK = 128
class ManagedCursor(sqlite3.Cursor):
"""A cursor whose calls into SQLite count as its connection being in use."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._pending = deque()
self._closed = False
def execute(self, sql, parameters=(), /):
conn = self.connection
conn._enter()
try:
return super().execute(sql, parameters)
finally:
conn._leave()
return self._run_statement(super().execute, sql, parameters)
def executemany(self, sql, seq_of_parameters, /):
return self._run_statement(super().executemany, sql, seq_of_parameters)
def _run_statement(self, operation: Callable, *args: Any) -> ManagedCursor:
conn = self.connection
conn._enter()
description = self.description
completed = False
try:
return super().executemany(sql, seq_of_parameters)
result = operation(*args)
completed = True
return result
finally:
# Argument parsing can fail before SQLite replaces the old statement.
if completed or self.description is not description:
self._pending.clear()
conn._leave()
def executescript(self, sql_script, /):
@@ -81,6 +94,8 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
if self._pending and not self._closed:
return self._pending.popleft()
return super().fetchone()
finally:
conn._leave()
@@ -89,7 +104,20 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
return super().fetchmany(*args, **kwargs)
# Let SQLite validate the original arguments, including version-specific
# handling of zero/negative sizes, before touching prefetched rows.
rows = super().fetchmany(*args, **kwargs)
if not self._pending:
return rows
size = operator.index(
kwargs.get("size", args[0] if args else self.arraysize)
)
# Python 3.12 reads all rows for size=0; 3.13+ reads none.
if size == 0 and sys.version_info >= (3, 13):
return rows
self._pending.extend(rows)
count = len(self._pending) if size <= 0 else min(size, len(self._pending))
return [self._pending.popleft() for _ in range(count)]
finally:
conn._leave()
@@ -97,25 +125,31 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
return super().fetchall()
rows = super().fetchall()
pending = list(self._pending)
self._pending.clear()
return pending + rows
finally:
conn._leave()
def __iter__(self) -> Iterator:
return self._chunks()
def _chunks(self) -> Iterator:
while True:
rows = self.fetchmany(ITER_CHUNK)
if not rows:
return
yield from rows
return self
def __next__(self):
conn = self.connection
pending = self._pending
# Buffered Python rows need no SQLite lock, but retirement and cursor
# close must still stop an iterator obtained before either operation.
if conn._retired or self._closed:
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
if pending:
return pending.popleft()
conn._enter()
try:
return super().__next__()
pending.extend(super().fetchmany(ITER_CHUNK))
if not pending:
raise StopIteration
return pending.popleft()
finally:
conn._leave()
@@ -126,7 +160,10 @@ class ManagedCursor(sqlite3.Cursor):
except sqlite3.ProgrammingError:
return super().close() # the connection is closed or closing
try:
return super().close()
result = super().close()
self._pending = deque()
self._closed = True
return result
finally:
conn._leave()
@@ -293,6 +330,9 @@ class ManagedConnection(sqlite3.Connection):
cursors = list(self._cursors)
try:
for cursor in cursors:
if isinstance(cursor, ManagedCursor):
cursor._pending = deque()
cursor._closed = True
with contextlib.suppress(sqlite3.Error):
sqlite3.Cursor.close(cursor)
super().close()
+1 -1
View File
@@ -34,7 +34,7 @@ from .history_reads import HistoryReadOperations
from .health import HealthOperations, iter_consistency_issues
from .duplicate_copies import DuplicateCopyOperations
from .workflow_storage import WorkflowStorageOperations
from .category_import import CategoryImportOperations, EmptyReplacement # noqa: F401
from .category_import import CategoryImportOperations
from .image_categories import ImageCategoryOperations
from .workflow_documents import (
DocumentReader,
+16 -3
View File
@@ -112,8 +112,14 @@ 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."""
"""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.
"""
with self._pair_transaction() as conn:
return self._save_pair(
conn,
@@ -124,6 +130,7 @@ class PairOperations:
tags=tags,
rating=rating,
notes=notes,
fill_new_positive=fill_new_positive,
)
def _save_pair(
@@ -137,6 +144,7 @@ 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():
@@ -144,8 +152,13 @@ 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.
_validate_metadata(title, category, tags, rating, notes)
positive = self._pair_component(conn, positive_text)
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 [])
negative = (
self._pair_component(conn, negative_text) if negative_text.strip() else None
)
+126 -20
View File
@@ -5,8 +5,12 @@ 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. ``unresolved`` records the decision and links nothing. No decision
counts a run, touches usage or calls an execution writer.
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.
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.
@@ -17,6 +21,7 @@ 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 (
@@ -30,9 +35,28 @@ from .review import (
same_name_rows,
)
from .review_changes import notify_review_change
from .review_schema import DECISIONS_TABLE, ITEMS_TABLE, canonical_evidence
from .review_schema import (
DECISIONS_TABLE,
ITEMS_TABLE,
canonical_evidence,
evidence_digest,
)
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) "
@@ -59,6 +83,8 @@ 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__(
@@ -93,6 +119,7 @@ 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,
@@ -101,7 +128,9 @@ class ReviewDecisionOperations:
) -> dict:
"""Record the decision; return ``{item, total, pair}``.
``selected`` needs the evidence ``candidate`` (with its ``hash``), the
``selected`` needs the evidence ``candidate`` (with its ``hash``) or
``manual`` (``{positive_id, pair_id}``; a null ``pair_id`` is no
negative), 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
@@ -115,9 +144,12 @@ class ReviewDecisionOperations:
if outcome not in ("selected", "unresolved"):
raise ValueError("outcome must be selected or unresolved")
if outcome == "selected" and (
not isinstance(candidate, dict) or not image_path
(not isinstance(candidate, dict)) == (not isinstance(manual, dict))
or not image_path
):
raise ValueError("A selected decision needs a candidate and a file")
raise ValueError(
"A selected decision needs one candidate or manual choice and a file"
)
try:
with self._pair_transaction() as conn:
decided = self._decide_in(
@@ -128,6 +160,7 @@ class ReviewDecisionOperations:
sha256=sha256,
outcome=outcome,
candidate=candidate,
manual=manual,
image_path=image_path,
metadata=metadata or {},
expected_fingerprint=expected_fingerprint,
@@ -158,13 +191,24 @@ class ReviewDecisionOperations:
raise _Rollback(ReviewCommandError("file-changed", epoch))
decision_id = _new_id()
pair = None
candidate = command["candidate"] if command["outcome"] == "selected" else 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
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": DECISION_KIND,
"kind": MANUAL_KIND if manual is not None else DECISION_KIND,
"decision_id": decision_id,
"outcome": command["outcome"],
"reviewed_digest": command["sha256"],
@@ -174,6 +218,8 @@ 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,
@@ -183,7 +229,7 @@ class ReviewDecisionOperations:
current["revision"] + 1,
now,
command["outcome"],
candidate["hash"] if candidate is not None else None,
candidate_hash,
canonical_evidence(evidence),
pair["id"] if pair else None,
pair["image_id"] if pair else None,
@@ -204,6 +250,77 @@ 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
@@ -242,17 +359,6 @@ 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,
+1
View File
@@ -46,6 +46,7 @@ def remove_quietly(*paths):
if path and os.path.exists(path):
os.remove(path)
except OSError:
# cleanup after a failure must not replace that failure
pass
-1
View File
@@ -66,7 +66,6 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
)
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
# "created" follow the id, so every order is total and keyset pages are exact.
TAG_SORTS = {
+8 -2
View File
@@ -18,9 +18,13 @@ 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 before deciding to send again; automatic retry must not overwrite edits.
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.
## PromptManager adapter
@@ -31,7 +35,9 @@ field. Unknown/ambiguous metadata must not become an empty string.
Previews show original and requested text plus positive prepend/append composition.
Connected composition is marked unknown rather than guessing upstream values.
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
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,
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,6 +230,8 @@ 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"]
+29 -8
View File
@@ -36,10 +36,18 @@ except ImportError:
try:
from ...utils import exit_signal
from ...utils.media_folders import is_skipped_folder
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 is_skipped_folder
from utils.media_folders import (
in_trash_below_roots,
in_trash_folder,
is_skipped_folder,
)
try:
from ...database.image_removal import mark_hides
@@ -168,7 +176,7 @@ def _physical_file_key(path, stat):
volumes on Windows) falls back to the resolved, case-folded path.
"""
if stat.st_ino:
return ("inode", stat.st_dev, stat.st_ino)
return ("inode", (stat.st_dev, stat.st_ino))
return ("path", os.path.normcase(os.path.realpath(str(path))))
@@ -531,6 +539,7 @@ def _diagnose_output_dirs(configured_dirs, public_path=None):
candidates.append(folder_paths.get_output_directory())
except ImportError:
# outside ComfyUI there is no output folder to add
pass
output_dirs = []
@@ -1089,13 +1098,21 @@ 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."""
"""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.
"""
file_path = Path(image_path)
if not file_path.is_absolute():
file_path = output_path / file_path
try:
rel_path = file_path.resolve().relative_to(output_path.resolve())
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)
except ValueError:
self.logger.warning(
f"Attempted to delete file outside output directory: {image_path}"
@@ -1104,15 +1121,19 @@ class AdminRoutesMixin:
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
return "not a media file"
if "thumbnails" in rel_path.parts:
if "thumbnails" in rel_path.parts or "thumbnails" in exact_rel.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(file_path)
os.remove(exact)
self.logger.info(f"Deleted duplicate image: {image_path}")
_remove_thumbnail(output_path.resolve(), rel_path, self.logger)
_remove_thumbnail(real_root, exact_rel, self.logger)
return None
async def get_statistics(self, request):
-1
View File
@@ -13,7 +13,6 @@ import math
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
LLM_MODEL_TYPES as LLM_MODELS,
MAX_INSTRUCTION_BYTES,
MAX_INSTRUCTION_CHARS,
MODEL_TYPES,
WD14_MODEL_TYPES as WD14_MODELS,
validate_template,
+16 -12
View File
@@ -24,9 +24,9 @@ from . import thumbnail_settings
from .. import model_gate
try:
from ...utils.media_folders import is_skipped_folder
from ...utils.media_folders import in_trash_below_roots, is_skipped_folder
except ImportError:
from utils.media_folders import is_skipped_folder
from utils.media_folders import in_trash_below_roots, is_skipped_folder
from aiohttp import web
@@ -424,6 +424,7 @@ class AutotagRoutesMixin:
title = f"Tagging model download · {label}"
_download_titles.clear()
_download_titles[owner] = title
admitted = False
try:
job = self._jobs.start(
MODEL_DOWNLOAD_JOB_KIND,
@@ -439,13 +440,13 @@ class AutotagRoutesMixin:
self._run_model_download, service, model_type, owner
),
)
except BaseException as refused:
# JobBusy, or anything else the registry raised: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
if isinstance(refused, JobBusy):
return None, busy_response(refused)
raise
admitted = True
except JobBusy as refused:
return None, busy_response(refused)
finally:
if not admitted: # JobBusy, or anything else: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
def ended(_task):
model_gate.release(owner)
@@ -849,9 +850,12 @@ 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
for output_dir in self._get_all_output_dirs():
roots = list(self._get_all_output_dirs())
for output_dir in roots:
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
@@ -1038,12 +1042,12 @@ class AutotagRoutesMixin:
return self._bad_request('existing must be "skip" or "merge"')
try:
options = self._single_options(data, model_type)
except OptionError as error:
return self._bad_request(str(error))
except (ValueError, TypeError):
return self._bad_request(
"general_threshold and character_threshold must be numeric"
)
except OptionError as error:
return self._bad_request(str(error))
target, error = await self._select_autotag_target(data)
if error is not None:
return error
+1
View File
@@ -971,6 +971,7 @@ class BackupRestoreRoutesMixin:
line = restored_line(staged.summary)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
async def _outlive_cancel(future):
+2 -1
View File
@@ -574,12 +574,13 @@ class DuplicateFilesMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return
return None
try:
await self._remove_duplicates(job, db, plan, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _remove_duplicates(self, job, db, plan, backup):
targets = plan.payload["targets"]
+9
View File
@@ -16,6 +16,11 @@ 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
@@ -197,6 +202,8 @@ 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:
@@ -212,6 +219,8 @@ 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:
+1 -1
View File
@@ -27,7 +27,7 @@ def _identity(path):
except OSError:
return None
if info.st_ino:
return ("inode", info.st_dev, info.st_ino)
return ("inode", (info.st_dev, info.st_ino))
return ("path", os.path.normcase(os.path.realpath(str(path))))
+8
View File
@@ -17,6 +17,7 @@ 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:
@@ -198,6 +199,9 @@ 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:
@@ -248,6 +252,10 @@ 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()
+2 -1
View File
@@ -330,12 +330,13 @@ class ImageDeleteMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return
return None
try:
await self._delete_images(job, db, targets, mode, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _delete_images(self, job, db, targets, mode, backup):
job.update(total=len(targets), status_line=TITLES[mode])
+7 -5
View File
@@ -531,6 +531,7 @@ class ImageTagJobsMixin:
run = AutotagRun(
model_type, existing, options, keep_in_memory, backup_on, owner
)
admitted = False
try:
job = self._jobs.start(
AUTOTAG_BATCH_JOB_KIND,
@@ -541,11 +542,12 @@ class ImageTagJobsMixin:
groups=AUTOTAG_GROUP_NAMES,
runner=functools.partial(self._run_autotag_batch, run),
)
except BaseException as refused:
model_gate.release(owner)
if isinstance(refused, JobBusy):
return None, _busy_refusal(refused)
raise
admitted = True
except JobBusy as refused:
return None, _busy_refusal(refused)
finally:
if not admitted:
model_gate.release(owner)
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return job, None
+53 -26
View File
@@ -39,10 +39,8 @@ from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
PreviewWriteHeld,
replace_unless_held,
run_encoder,
sweep_stray_temps,
temp_sibling,
thumbnail_frame,
writer_settings,
write_thumbnail,
@@ -66,7 +64,12 @@ try:
from ...database.models import normalize_image_path
from ...database.run_attribution import recorded_output_lookup
from ...utils.card_seed import card_seed, header_params, parsed_positive
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
from ...utils.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 (
@@ -80,7 +83,7 @@ try:
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
is_same_origin_request,
reveal_executor,
reveal_in_file_manager,
)
@@ -92,7 +95,12 @@ except ImportError:
from database.models import normalize_image_path
from database.run_attribution import recorded_output_lookup
from utils.card_seed import card_seed, header_params, parsed_positive
from utils.media_folders import in_skipped_folder, is_skipped_folder
from utils.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 (
@@ -106,7 +114,7 @@ except ImportError:
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
is_same_origin_request,
reveal_executor,
reveal_in_file_manager,
)
@@ -277,10 +285,20 @@ def _forbidden(message="Access denied"):
return web.json_response({"success": False, "error": message}, status=403)
def _media_access_error(path, allowed_dirs):
"""Return a 403 response when *path* must not be served, else None.
def _file_not_found():
return web.json_response(
{"success": False, "error": "Image file not found"},
status=404,
headers={"Cache-Control": "no-store"},
)
Fails closed: an empty *allowed_dirs* denies everything.
def _media_access_error(path, allowed_dirs):
"""The response refusing *path*, or None when it may be read.
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.
"""
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
@@ -288,6 +306,8 @@ 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
@@ -317,11 +337,6 @@ def _thumbnail_batch_size(workers):
return max(4, workers * 2)
# Preview files are written by thumbnail_encode in the configured format
# (thumbnail_settings, #317); these names stay importable from here.
_temp_sibling = temp_sibling
_replace_unless_held = replace_unless_held
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
# over a file another process has open (WinError 5 or 32), such as a preview
# the gallery is serving; the old preview stays and the file counts as an error.
@@ -374,6 +389,7 @@ def _video_placeholder_image(thumbnail_size):
font=font,
)
except (OSError, AttributeError):
# a placeholder without its label still works
pass
return img
@@ -477,6 +493,7 @@ def _preview_names(folders, folder):
except OSError:
continue
except OSError:
# an unreadable folder lists as empty; nothing there to cache
pass
folders[folder] = names
return names
@@ -795,12 +812,12 @@ def _reveal_not_found():
)
def _not_local():
def _not_same_origin():
return web.json_response(
{
"success": False,
"code": "not-local",
"error": "Show in folder works only on the computer running ComfyUI.",
"code": "not-same-origin",
"error": "This page's address does not match the server's.",
},
status=403,
)
@@ -979,6 +996,8 @@ def _settled_entry(root, relative):
return None
if in_skipped_folder(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
@@ -1514,7 +1533,7 @@ class ImageRoutesMixin:
async def clear_image_favorite_route(request):
return await self.update_image_favorite(request, favorite=False)
# POST, local-only: opens this computer's file manager (D38).
# POST, same-origin: opens the server computer's file manager (D38).
@routes.post("/prompt_manager/images/reveal")
async def reveal_image_route(request):
return await self.reveal_image(request)
@@ -2761,6 +2780,9 @@ 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
@@ -2784,6 +2806,8 @@ 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)
@@ -2835,8 +2859,9 @@ class ImageRoutesMixin:
if _is_thumbnail_cache_path(filepath):
return _forbidden()
image_path = root / filepath
if not _is_within(image_path, root):
return _forbidden()
denied = _media_access_error(image_path, [root])
if denied is not None:
return denied
if not image_path.is_file():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
@@ -3682,12 +3707,12 @@ class ImageRoutesMixin:
async def reveal_image(self, request):
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
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).
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).
"""
if not is_local_request(request.remote, request.headers, request.scheme):
return _not_local()
if not is_same_origin_request(request.headers, request.scheme):
return _not_same_origin()
if request.content_type != "application/json":
# A non-JSON POST is a CORS "simple" request with no preflight.
return web.json_response(
@@ -3765,7 +3790,9 @@ class ImageRoutesMixin:
{
"success": False,
"code": "unavailable",
"error": "No file manager is available on this computer.",
"error": (
"No file manager is available on the computer running ComfyUI."
),
},
status=501,
)
+1 -1
View File
@@ -250,7 +250,7 @@ class JobRoutesMixin:
try:
await response.write(_summary_event(job))
async with contextlib.aclosing(job.events()) as events:
async for _payload in events:
async for _ in events:
await response.write(_summary_event(job))
# The last event is published before the job takes its final state.
await response.write(_summary_event(job))
+2
View File
@@ -207,6 +207,7 @@ class LibraryLocationRoutesMixin:
try:
size = os.path.getsize(location.db_path)
except OSError:
# no library file yet: report size 0
pass
return {
"path": self._library_public_path(location.db_path),
@@ -373,6 +374,7 @@ class LibraryLocationRoutesMixin:
line += ". Logs move at the next restart."
job.log_line(line)
job.update(progress=100, status_line=line)
return None
@staticmethod
def _fail_library_move(job, code, message):
+1
View File
@@ -205,3 +205,4 @@ class LogCleanupMixin:
line += f", {failed} could not be deleted"
job.log_line(line)
job.update(processed=len(files), progress=100, status_line=line)
return None
+5 -3
View File
@@ -436,7 +436,7 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return
return None
except Exception:
self.logger.exception("LoRA import staging failed")
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
@@ -455,10 +455,10 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return
return None
ok, _backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return
return None
# I1: the files are listed and fingerprinted again right before the
# replace, so a file changed, removed or added while the downloads ran
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
@@ -466,6 +466,7 @@ class LoraImportRoutesMixin:
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
job.update(status_line="Replacing the LoRA collection", progress=95)
await self._replace_in_job(job, db, plan, staged)
return None
def _count_downloads(self, job, staged):
downloaded = staged.stats.get("downloaded", 0)
@@ -518,3 +519,4 @@ class LoraImportRoutesMixin:
staged.stats.get("download_errors", 0),
)
job.update(progress=100, processed=job.total, status_line=line)
return None
+15 -2
View File
@@ -44,6 +44,7 @@ _FILES_CHANGED = (
"The LoRA metadata files changed during the scan; the previous imports "
"were kept. Run the scan again."
)
_LORA_SCAN_FAILED = "The LoRA import failed. Check the server log for details."
_IMPORTS_CHANGED = (
"The imported LoRA prompts changed during the scan; the previous imports "
"were kept. Run the scan again."
@@ -407,10 +408,21 @@ class LoraIntegrationMixin:
return
if isinstance(error, PlanChanged):
message = _IMPORTS_CHANGED
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
elif isinstance(
error,
(
LoraFilesChanged,
LoraSettingsUnusable,
EmptyReplacement,
PairDependencyError,
),
):
message = str(error)
else:
elif isinstance(error, OSError):
# Reduced to its reason and the file's basename.
message = self._public_error(error)
else:
message = _LORA_SCAN_FAILED
if isinstance(
error,
(
@@ -536,6 +548,7 @@ class LoraIntegrationMixin:
except asyncio.CancelledError:
interrupted = True
except Exception:
# the await after this loop raises it for the caller
pass
if interrupted:
raise asyncio.CancelledError
+1
View File
@@ -673,6 +673,7 @@ class MaintenanceRoutesMixin:
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
return None
def _maintenance_rows(self, operation, described):
"""Outcome rows for the changed records (root-relative paths only)."""
+8 -2
View File
@@ -9,13 +9,15 @@ by the caller.
import os
import re
from .images import _forbidden, _has_traversal
from .images import _file_not_found, _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"})
@@ -29,7 +31,9 @@ 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*).
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.
"""
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
return None, bad_request("target must name root_index, root_scope and path")
@@ -51,4 +55,6 @@ 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
+6 -1
View File
@@ -102,7 +102,12 @@ class PairRoutesMixin:
validate_prompt_text(negative)
fields = metadata_fields(data)
pair_id = await self._run_in_executor(
self.db.save_prompt_pair, positive, negative, **fields
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,
)
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
return web.json_response({"success": True, "pair": pair})
+59 -50
View File
@@ -9,7 +9,10 @@ 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.
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.
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
@@ -28,12 +31,13 @@ import urllib.parse
from aiohttp import web
from .annotations import _HEX64, _TOKEN, _UUID, _capture_error, _error
from .annotations import _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
@@ -59,17 +63,6 @@ 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
@@ -84,6 +77,7 @@ _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.",
@@ -95,6 +89,9 @@ _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."
@@ -251,6 +248,7 @@ 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 ----------------------------------------------------------------
@@ -464,6 +462,43 @@ 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:
@@ -549,22 +584,25 @@ class ReviewRoutesMixin:
return None, _coded("file-changed")
metadata = None
if need_metadata:
if captured is None or not _candidate_still_offered(
captured[1], guards["choice"]
# 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"])
):
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):
def _decide_sync(self, item, guards, candidate, verified, manual=None):
"""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
if candidate is not None:
selected = candidate is not None or manual is not None
if selected:
aliases, known = physical_spellings(self.db, image_path, item["locator"])
try:
result = self.db.decide_review_item(
@@ -573,8 +611,9 @@ 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 candidate is not None else UNRESOLVED,
outcome="selected" if selected else UNRESOLVED,
candidate=candidate,
manual=manual,
image_path=image_path,
metadata=metadata,
expected_fingerprint=guards["dependency_fingerprint"],
@@ -589,11 +628,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):
async def _record_decision(self, item, guards, candidate, verified, manual=None):
capture = verified[0] if verified else None
try:
result, view = await self._run_in_executor(
self._decide_sync, item, guards, candidate, verified
self._decide_sync, item, guards, candidate, verified, manual
)
except ReviewCommandError as exc:
return self._decision_refused(exc)
@@ -629,36 +668,6 @@ 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
@@ -0,0 +1,66 @@
"""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
+9 -2
View File
@@ -41,6 +41,13 @@ 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
@@ -71,7 +78,7 @@ class SourceRescopeMixin:
path = root / relative
denied = _media_access_error(path, [root])
if denied is not None:
return denied
return _missing_if_not_found(denied)
try:
mtime = path.stat().st_mtime if path.is_file() else None
except OSError:
@@ -89,7 +96,7 @@ class SourceRescopeMixin:
return bad_request("Send exactly one target")
target, error = media_target(data["target"])
if error is not None:
return error
return _missing_if_not_found(error)
old_index, scope, relative = target
if old_index > MAX_ROOT_INDEX:
return bad_request("Invalid gallery root")
+7 -1
View File
@@ -67,6 +67,7 @@ from aiohttp import web
from . import thumbnail_settings
from .images import (
in_trash_below_roots,
MEDIA_EXTENSIONS,
THUMBNAIL_CLEAR_TITLE,
VIDEO_EXTENSIONS,
@@ -224,6 +225,8 @@ 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
@@ -248,6 +251,8 @@ 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):
@@ -528,6 +533,7 @@ class AutoThumbnailer:
try:
process.kill()
except OSError:
# the process has already exited
pass
if worker is None:
return True
@@ -835,7 +841,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]:
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
return None
dst = root / media_thumbnail_rel(rel)
if not _is_within(root / "thumbnails", root) or not _is_within(
+4
View File
@@ -158,6 +158,7 @@ def _unlink_quietly(path):
try:
Path(path).unlink()
except FileNotFoundError:
# already gone, which is what was wanted
pass
except OSError as exc:
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
@@ -221,6 +222,7 @@ def shutdown_encoders():
try:
process.kill()
except OSError:
# the process has already exited
pass
@@ -359,10 +361,12 @@ def thumbnail_frame(img, edge):
try:
img.seek(0)
except (EOFError, AttributeError, ValueError):
# not seekable or a single frame: use the current one
pass
try:
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
except (AttributeError, ValueError, OSError):
# draft is only a speedup; the full decode below still works
pass
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
+1
View File
@@ -275,3 +275,4 @@ class WorkflowStorageRoutesMixin:
job.record_counted("changed", 1)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
+1
View File
@@ -109,6 +109,7 @@ def _write_atomically(path: str, payload: bytes) -> None:
try:
os.unlink(temp_path)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
+1
View File
@@ -642,6 +642,7 @@ def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
relative = Path(settings_file).relative_to(home)
settings_file = os.path.join("~", str(relative))
except ValueError:
# outside the home folder: show the full path
pass
return {
"source": layout.source,
+2
View File
@@ -585,6 +585,7 @@ def _read_capped(resp, cap: int) -> Optional[bytes]:
if int(declared) > cap:
return None
except ValueError:
# a bad header says nothing; the read below enforces the cap
pass
chunks = []
@@ -707,6 +708,7 @@ def _save_atomically(image, local_path: Path) -> None:
try:
os.remove(temp)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.3.1"
version = "3.4.0"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
-1
View File
@@ -6,7 +6,6 @@ import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
const workspace = '/prompt_manager/workspace';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
+23 -1
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 => path, queuePrompt: async (...args) => args};'}));
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('**/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,6 +64,28 @@ 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');
+14
View File
@@ -111,9 +111,23 @@ 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();
}
+8
View File
@@ -8,6 +8,9 @@
// 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 })`.
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';
@@ -44,6 +47,11 @@ 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/web','_blank']);
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/workspace#/library','_blank']);
});
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
+59 -6
View File
@@ -362,10 +362,14 @@ 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 this computer.');
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);
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 this computer.'],['fail','The folder could not be opened. Try again.']]) {
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.']]) {
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
await show.click();await expect(toast).toHaveText(message);
}
@@ -373,11 +377,56 @@ 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 request counts as remote, so the real route answers 403 not-local.
// A proxied click opens the folder: the proxy's headers, not the browser's address, name the page (#533).
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('Show in folder works only in a browser on the computer running ComfyUI.');
expect(await opened()).toEqual(['output/inspector/complete.png']);
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');
});
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);
@@ -415,7 +464,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:'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:'#pair 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();}
@@ -627,6 +676,10 @@ 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 destination' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#canonical-pair 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.');
+1 -1
View File
@@ -194,7 +194,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:'Concept destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 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());
+4 -1
View File
@@ -474,7 +474,10 @@ 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([]);
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card 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([]);
// 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 -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:'My prompt pair'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 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
@@ -0,0 +1,13 @@
// 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;
}
+4 -2
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:'Concept destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 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,7 +374,9 @@ 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']);
expect(actual).toEqual(reference);
// 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);
// 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);
+4 -3
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:'My prompt pair'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
return sender;
}
@@ -165,7 +165,8 @@ 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/);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
// D175: a refusal wrote nothing, so Apply may try again.
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
@@ -193,7 +194,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 compatible ComfyUI windows found.');
const sender=dialog(page);await expect(sender).toContainText('No ComfyUI window answered in this browser');
const host=await fakeHost(context,page);
try {
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
+6 -2
View File
@@ -26,6 +26,10 @@ 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);
@@ -90,7 +94,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.at(-1)).toEqual(['Leave unresolved', false]);
expect(node(surface, 'select').options.map(option => option[0])).not.toContain('Leave unresolved'); // D179
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);
@@ -169,7 +173,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.prepend(row.lastElementChild));
await panel(page).locator('.dialog-actions').evaluate(row => row.append(row.firstElementChild));
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
});
+1
View File
@@ -109,6 +109,7 @@ class ResetWatch:
stream.write(text)
stream.flush()
except (OSError, ValueError):
# the stream closed; a watcher must not fail the server
pass
+11 -3
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. This pins the
// concept's `.surface + .surface` 18px offset, which concept.css applies inside `.two-col` too (copied, not ruled).
// 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.
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,6 +165,8 @@ 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 = {
@@ -257,7 +259,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',
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
].sort();
test('system comparison detects mutations', async ({ page, request }) => {
@@ -683,6 +685,8 @@ 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);
@@ -693,8 +697,11 @@ 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);
@@ -737,6 +744,7 @@ 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 always asks for the ComfyUI window and node, so “Ask each time” is the only option.';
const SEND_REASON = 'Sending asks when there is more than one ComfyUI window or 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,6 +231,9 @@ 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');
@@ -279,7 +282,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.slice(0,-1),select.options.at(-1)]}:select;
return select.id==='review-context'?{...select,options:twin.options}:select; // D179: no Leave unresolved option
});
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,5 +1,6 @@
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';
@@ -116,6 +117,7 @@ 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. */
@@ -764,6 +764,11 @@ 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++; };
+3 -1
View File
@@ -231,6 +231,8 @@ 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();
@@ -468,7 +470,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 destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
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 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();
+71 -3
View File
@@ -1,6 +1,7 @@
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).
@@ -62,7 +63,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 primary.click();
await startAndAwaitJob(page, () => 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);
@@ -81,11 +82,25 @@ test('a finished delete re-reads the sidebar Images count', async ({ page }) =>
const before = Number((await badge.textContent()).replace(/,/g, ''));
await check(page, 0);
const { primary } = await deleteChecked(page, 'library');
await primary.click();
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);
@@ -169,13 +184,66 @@ 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 primary.dblclick();
await startAndAwaitJob(page, () => 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,6 +1,7 @@
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.
@@ -46,7 +47,7 @@ async function removeFromLibrary(page, count) {
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 dialog.locator('.dialog-actions button.danger').click();
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;
@@ -78,7 +79,7 @@ test('Show removed images lists the removed files; Return to library brings them
await check(page, 1);
const actions = bar(page).locator('button');
await expect(actions).toHaveText(['Return to library', 'Clear']);
await bar(page).getByRole('button', { name: 'Return to library' }).click();
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.
@@ -98,7 +99,7 @@ test('Return to library from the inspector', async ({ page }) => {
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 panel.getByRole('button', { name: 'Return to library' }).click();
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);
@@ -112,7 +113,7 @@ test('the sidebar Images count follows a remove and a return', async ({ page })
await expect(imagesBadge(page)).toHaveText((start - 1).toLocaleString('en-US'));
await toggle(page).click();
await check(page, 0);
await bar(page).getByRole('button', { name: 'Return to library' }).click();
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'));
});
+2 -1
View File
@@ -602,7 +602,8 @@ async function staleSourceRecovery(page, request, roots) {
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname.endsWith('/images/output')) pages.push(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.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: 'Inspect destination' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#pair-1 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.');
@@ -0,0 +1,195 @@
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);
});
+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')sorts.push(url.searchParams.get('sort'));});
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
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:'Library destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 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:'Card destination'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#positive-1 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}]);
@@ -0,0 +1,153 @@
// 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();
});
+93 -23
View File
@@ -15,8 +15,11 @@ 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',
'Leave unresolved'];
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 });
async function seed(request, items = 3, special = {}) {
const response = await request.post('/__test__/review/reset', { data: { items, ...special } });
@@ -44,7 +47,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(context(page)).toBeEnabled();
await expect(leaveUnresolved(page)).not.toHaveAttribute('aria-disabled', 'true');
}
test.beforeEach(async ({ page, browserName }) => {
@@ -80,8 +83,6 @@ 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');
@@ -90,6 +91,14 @@ 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.
@@ -105,7 +114,9 @@ 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.');
await expect(page).toHaveURL(/#\/images$/);
// #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(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.
@@ -135,20 +146,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 context(page).selectOption({ label: 'Leave unresolved' });
await apply(page).click();
await leaveUnresolved(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 and an unavailable file can only be left unresolved', async ({ page, request }) => {
test('a limited item can be linked or left unresolved; an unavailable file only 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).locator('option')).toHaveText(['Leave unresolved']);
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. You can leave it unresolved.');
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/);
// 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');
@@ -166,7 +177,8 @@ test('a limited item and an unavailable file can only be left unresolved', async
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 apply(page).click();
await expect(linkButton(page)).toHaveAttribute('aria-disabled', 'true');
await leaveUnresolved(page).click();
await expect(toast(page)).toHaveText('Left unresolved.');
expect(observes).toEqual([]);
expect(await pending(request)).toBe(1);
@@ -175,20 +187,20 @@ test('a limited item and an unavailable file can only be left unresolved', async
test('leave unresolved removes the item from pending', async ({ page, request }) => {
await seed(request, 2);
await open(page);
await context(page).selectOption({ label: 'Leave unresolved' });
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
await leaveUnresolved(page).click();
await expect(toast(page)).toHaveText('Left unresolved.');
await expect(page).toHaveURL(/#\/images$/);
await expect(page).toHaveURL(/#\/review$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
expect(await pending(request)).toBe(1);
});
test('leave for later writes nothing and goes to Images', async ({ page, request }) => {
test('leave for later writes nothing and opens the next item', 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(page).toHaveURL(/#\/images$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
expect(writes).toEqual([]);
expect(await pending(request)).toBe(2);
});
@@ -332,7 +344,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(-2)).toEqual(['Leave for later', 'Apply association']);
expect(order.slice(-3)).toEqual(['Leave for later', 'Leave unresolved', '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);
@@ -350,10 +362,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 returns to Images.
// Enter on Leave for later stays on the page; the only item stays open.
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/#\/images$/);
await expect(page).toHaveURL(/#\/review$/);
});
test('an open page shows items a scan adds and keeps the open choice', async ({ page, request }) => {
@@ -391,9 +403,8 @@ 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 context(other).selectOption({ label: 'Leave unresolved' });
await apply(other).click();
await expect(other).toHaveURL(/#\/images$/);
await leaveUnresolved(other).click();
await expect(toast(other)).toHaveText('Left unresolved.');
await other.close();
await expect(cards(page)).toHaveCount(1);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
@@ -613,3 +624,62 @@ 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 -1
View File
@@ -233,7 +233,7 @@ test('a view stored under older rules still lists and opens with its usable valu
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
@@ -0,0 +1,322 @@
// #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 }) => { await saveCoverage(page, browserName); });
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();
});

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