Compare commits

..
Author SHA1 Message Date
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
105 changed files with 5758 additions and 761 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
+7 -6
View File
@@ -251,9 +251,9 @@ match Classic and Focused.
- **PromptManagerText**: Look for "Prompt Manager Text" in the PromptManager/Text category - outputs STRING for any text node
5. **Access the web interface**:
- **Admin Dashboard**: `http://localhost:8188/prompt_manager/admin` - Full management interface
- **Metadata Viewer**: `http://localhost:8188/prompt_manager/metadata.html` - Standalone PNG analysis tool
- **Simple Interface**: `http://localhost:8188/prompt_manager/` - Basic prompt browser
- **Workspace**: click **PM** in ComfyUI's top bar or **Web UI** on a node, or open `http://localhost:8188/prompt_manager/workspace`
- **Legacy pages** still work at their URLs, and System's Overview section links to them:
`/prompt_manager/admin` (dashboard), `/prompt_manager/admin#/tags` (tags), `/prompt_manager/gallery` (gallery), `/prompt_manager/gallery.html` (metadata viewer)
### Prerequisites
@@ -324,9 +324,10 @@ The comprehensive gallery system provides complete access to all your ComfyUI ou
#### **📁 Accessing the Gallery**
1. **Open the admin interface** at `http://localhost:8188/prompt_manager/admin`
2. **Click the "🖼️ Gallery" button** to open the output-wide gallery
3. **Browse all content** from your ComfyUI output directory
1. **Open the workspace's Images page** at `http://localhost:8188/prompt_manager/workspace#/images`
2. **Browse all content** from your ComfyUI output directory
The legacy gallery stays at `http://localhost:8188/prompt_manager/gallery`.
#### **🎬 Video & Image Support**
+175 -32
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
@@ -4358,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
@@ -5635,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
@@ -7047,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
@@ -7170,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`).
+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,
+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"]
+27 -7
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
@@ -1089,13 +1097,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 +1120,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):
+6 -3
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
@@ -849,9 +849,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
+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:
+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()
+51 -19
View File
@@ -66,7 +66,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 +85,7 @@ try:
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
is_same_origin_request,
reveal_executor,
reveal_in_file_manager,
)
@@ -92,7 +97,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 +116,7 @@ except ImportError:
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
is_same_origin_request,
reveal_executor,
reveal_in_file_manager,
)
@@ -277,10 +287,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 +308,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
@@ -795,12 +817,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 +1001,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 +1538,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 +2785,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 +2811,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 +2864,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 +3712,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 +3795,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,
)
+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")
+6 -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):
@@ -835,7 +840,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(
+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})=>{
+58 -5
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);
@@ -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);
+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');
+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;
}
+3 -1
View File
@@ -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);
+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([]);
});
+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,
@@ -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++; };
+2
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();
+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);
});
@@ -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);
});
+1 -1
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);
@@ -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([]);
});
@@ -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 }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
for (const viewport of [null, PHONE]) {
const label = viewport ? ' at 390px' : '';
test(`Images: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
if (viewport) await page.setViewportSize(viewport);
await openImages(page, request);
await scrollToY(page, 1500);
const card = await cardBelow(imageCards(page), 300);
const before = (await box(card)).top;
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page).locator('strong')).toHaveText('1 selected');
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
// Keep scrolling and selecting: the bar stays; Clear returns the card to its place too.
await scrollToY(page, 1900);
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
const later = await cardBelow(imageCards(page), 300);
await later.locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page).locator('strong')).toHaveText('2 selected');
const held = (await box(later)).top;
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await expect(imageBar(page)).toHaveCount(0);
expect(Math.abs((await box(later)).top - held)).toBeLessThanOrEqual(1);
});
test(`Library: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
if (viewport) await page.setViewportSize(viewport);
await openLibrary(page, request);
await scrollToY(page, 2000);
await expect(promptCards(page).first()).toBeAttached();
const card = await cardBelow(promptCards(page), 250);
const before = (await box(card)).top;
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(libraryBar(page).locator('strong')).toHaveText('1 selected');
expect(inViewport(await box(libraryBar(page)), page)).toBe(true);
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
expect(await card.isVisible()).toBe(true);
});
}
test('the Images panel clears the stuck bar and a focused card is not left under it', async ({ page, request }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 300)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
await expect.poll(async () => (await box(page.locator('#image-inspector'))).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
expect(overlaps(await box(page.locator('#image-inspector')), await box(imageBar(page)))).toBe(false);
// A control scrolled to by focus lands below the bar; the bar's own controls do not move the page.
const y = await page.evaluate(() => scrollY);
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
expect(await page.evaluate(() => scrollY)).toBe(y);
const above = imageCards(page).first().locator('.image-overlays input[type="checkbox"]');
await above.focus();
expect((await box(above)).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
});
test('Back to top shows off the top of a page, goes to the top and hands focus to the page', async ({ page, request }) => {
await openImages(page, request);
await expect(chip(page)).toBeHidden();
await scrollToY(page, 1500);
await expect(chip(page)).toBeVisible();
const loaded = await page.evaluate(() => document.querySelector('#result-count').textContent);
await chip(page).click();
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
await expect(chip(page)).toBeHidden();
await expect(page.locator('#workspace-main')).toBeFocused();
expect(await page.evaluate(() => document.querySelector('#result-count').textContent)).toBe(loaded);
await expect(imageCards(page).first()).toBeInViewport();
});
test('Back to top on the virtualized Library lands on the first card and stays there', async ({ page, request }) => {
await openLibrary(page, request, 200);
// Past 120 loaded prompts the shelf mounts only a window of them (each pass reaches the next page's trigger).
await expect.poll(async () => {
await page.evaluate(() => scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }));
return page.locator('article[data-index="0"]').count();
}, { timeout: 30000 }).toBe(0);
await expect(chip(page)).toBeVisible();
await chip(page).click();
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
await expect.poll(async () => Number(await promptCards(page).first().getAttribute('data-index'))).toBe(0);
await expect(promptCards(page).first()).toBeInViewport();
expect(await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(scrollY)))))).toBe(0);
});
test('Back to top jumps, under reduced motion and otherwise', async ({ page, request }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await openImages(page, request, 20);
await scrollToY(page, 600);
await expect(chip(page)).toBeVisible();
const after = await chip(page).evaluate(node => { node.click(); return scrollY; });
expect(after).toBe(0);
});
for (const viewport of [{ width: 1280, height: 720 }, PHONE]) {
test(`Back to top moves clear of a toast and of the bar at ${viewport.width}px`, async ({ page, request }) => {
await page.setViewportSize(viewport);
await openImages(page, request);
await scrollToY(page, 1500);
await expect(chip(page)).toBeVisible();
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
expect(overlaps(await box(chip(page)), await box(imageBar(page)))).toBe(false);
const alone = await box(chip(page));
await page.evaluate(() => {
document.getElementById('workspace-toast').textContent = 'A much longer message than most, one that keeps going, so that it wraps to more than two lines on the narrowest screen a phone will give this page to show it on, and then some more words again.';
});
await expect(toast(page)).not.toBeEmpty();
await expect.poll(async () => overlaps(await box(chip(page)), await box(toast(page)))).toBe(false);
expect((await box(chip(page))).bottom).toBeLessThanOrEqual((await box(toast(page))).top);
await expect(chip(page)).toBeInViewport();
await page.evaluate(() => { document.getElementById('workspace-toast').textContent = ''; });
await expect.poll(async () => Math.round((await box(chip(page))).bottom)).toBe(Math.round(alone.bottom));
});
}
test('a stuck bar at 390px is one row that keeps every action reachable', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
expect((await box(imageBar(page))).bottom - (await box(imageBar(page))).top).toBeLessThan(80);
const clear = imageBar(page).getByRole('button', { name: 'Clear', exact: true });
await clear.scrollIntoViewIfNeeded();
await expect(clear).toBeInViewport();
await clear.click();
await expect(imageBar(page)).toHaveCount(0);
});
test('the Needs review panel stays with you, and its long content scrolls inside it', async ({ page, request }) => {
await page.setViewportSize({ width: 1400, height: 500 });
await openReview(page, request);
await scrollToY(page, 700);
await expect(reviewPanel(page)).toBeInViewport();
const rect = await box(reviewPanel(page));
expect(rect.top).toBeGreaterThanOrEqual(0);
expect(rect.bottom).toBeLessThanOrEqual(500);
const card = await cardBelow(reviewCards(page), 100);
await card.getByRole('button', { name: /^Inspect / }).click();
const name = await card.locator('.media-info').first().textContent();
expect(name).toBeTruthy();
await expect(reviewPanel(page)).toBeInViewport();
expect((await box(reviewPanel(page))).top).toBe(rect.top);
});
test('at 390px Info on a lower review card brings the panel into view', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openReview(page, request);
await expect(reviewPanel(page)).not.toBeInViewport();
const card = reviewCards(page).nth(3);
await card.scrollIntoViewIfNeeded();
await card.getByRole('button', { name: /^Inspect / }).click();
await expect(reviewPanel(page)).toBeInViewport();
});
// D189: at one column the Images panel follows the whole list: Info goes there, and a batch doesn't push it away.
test('at 390px Info on an image brings its panel into view and loading waits for the next move', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openImages(page, request);
expect(await page.evaluate(() => scrollY)).toBe(0);
await expect(page.locator('#image-inspector')).not.toBeInViewport();
let reads = 0;
page.on('request', item => { if (new URL(item.url()).pathname.endsWith('/images/recent')) reads++; });
const card = imageCards(page).nth(3);
await card.scrollIntoViewIfNeeded();
await card.getByRole('button', { name: /^Inspect / }).click();
await expect(page.locator('#image-inspector')).toBeInViewport();
await expect(page.locator('#viewer-dialog')).toBeHidden();
const held = (await box(page.locator('#image-inspector'))).top;
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect((await box(page.locator('#image-inspector'))).top).toBe(held);
expect(reads).toBe(0);
// The next move releases it: loading resumes.
const next = page.waitForRequest(item => new URL(item.url()).pathname.includes('/images'));
await page.mouse.wheel(0, 40);
await next;
});
// Review's review-state load more is a button, so Library's anchor stays honest through a compensated bar.
test('a Library selection does not jump after a pending height correction', async ({ page, request }) => {
await page.setViewportSize({ width: 1000, height: 800 });
await openLibrary(page, request);
await scrollToY(page, 3000);
const card = await cardBelow(promptCards(page), 250);
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(libraryBar(page)).toBeVisible();
const before = (await box(card)).top;
await page.setViewportSize({ width: 1300, height: 800 });
await expect(card).toBeAttached();
await page.setViewportSize({ width: 1000, height: 800 });
await expect.poll(async () => Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
});
// #531: after a bulk action on a bar, the page shows the refreshed list from the top.
async function selectFarDownImages(page, request) {
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
}
const atTop = page => expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
const applyDialog = async (page, title, value) => {
const dialog = page.getByRole('dialog', { name: title, exact: true });
await dialog.locator('#action-input').fill(value);
await dialog.getByRole('button', { name: /^(Apply|Add)/ }).click();
await expect(dialog).toHaveCount(0);
};
test('Images: Set category returns to the top of the refreshed list', async ({ page, request }) => {
await selectFarDownImages(page, request);
const read = page.waitForRequest(item => item.url().includes('/prompt_manager/images/'));
await imageBar(page).getByRole('button', { name: 'Set category', exact: true }).click();
await applyDialog(page, 'Categorize selected images', 'Studio');
await read;
await atTop(page);
await expect(imageBar(page)).toHaveCount(0);
await expect(imageCards(page).first()).toBeInViewport();
});
test('Images: Add tags returns to the top of the refreshed list when its job ends', async ({ page, request }) => {
await selectFarDownImages(page, request);
await imageBar(page).getByRole('button', { name: 'Add tags', exact: true }).click();
await applyDialog(page, 'Tag selected images', 'sticky-tag');
await atTop(page);
await expect(imageBar(page)).toBeVisible();
});
test('Library: Set category and Add tags return to the top of the reloaded shelf', async ({ page, request }) => {
await openLibrary(page, request);
for (const [button, title, value] of [['Set category', 'Categorize selected prompts', 'Studio'], ['Add tags', 'Tag selected prompts', 'sticky']]) {
await scrollToY(page, 2000);
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
await libraryBar(page).getByRole('button', { name: button, exact: true }).click();
await applyDialog(page, title, value);
await atTop(page);
await expect(libraryBar(page)).toHaveCount(0);
await expect(promptCards(page).first()).toBeInViewport();
}
});
test('Images: Delete… returns to the top of the reloaded list', async ({ page, request }) => {
await selectFarDownImages(page, request);
await imageBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = page.locator('dialog[open]');
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await startAndAwaitJob(page, () => dialog.locator('.dialog-actions button.danger').click());
await expect(toast(page)).toContainText('Removed 1 image from the library.');
await atTop(page);
await expect(page.locator('#bulkbar')).toBeEmpty();
});
test('Library: Delete… returns to the top of the reloaded shelf', async ({ page, request }) => {
await openLibrary(page, request);
await scrollToY(page, 2000);
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
await libraryBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await page.locator('dialog[open] .dialog-actions button.danger').click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await atTop(page);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
@@ -41,6 +41,9 @@ async function serverJson(request, url) {
return response.json();
}
// `openSources` dismisses the notice itself unless a test asks for it.
test.use({ olderImagesNotice: true });
/** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */
async function openSources(page, { notice = false } = {}) {
if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
+3
View File
@@ -897,6 +897,8 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
// System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed
// `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs
// checks the rest of each row.
test.describe(() => {
test.use({ olderImagesNotice: true });
test('every covered legacy System capability has its native control', async ({ page, request }) => {
// Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is
// missing here.
@@ -907,6 +909,7 @@ test('every covered legacy System capability has its native control', async ({ p
await request.post('/__test__/tagging/reset', { data: {} });
}
});
});
async function findControls(page) {
const covered = readCoverageTable().filter(row => row.Status === 'covered')
@@ -0,0 +1,313 @@
// #532 (TESTING.md D170): ← and → move through the viewer's files wherever focus is, except where the arrows belong to
// what has focus; ⌘ beside the view switch lists the shortcuts in an overlay that closes on its own.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openProduction } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const CARDS = '.gallery-body > .image-grid > .media-card';
const viewer = page => page.locator('dialog#viewer-dialog[open]');
const position = page => viewer(page).locator('.media-position');
const keysButton = page => viewer(page).locator('.dialog-header > button#viewer-keys');
const ALL_KEYS = ['←', '→', 'Space', '+', '-', '0', 'R', 'H', 'F', 'I', '?', 'Esc', 'D', 'Delete'];
const overlay = page => page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true });
test.beforeEach(async ({ page, request, browserName }, testInfo) => {
await startCoverage(page, browserName);
testInfo.manifest = await seedConcept(request);
});
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
async function openImages(page, width = 1600) {
await page.setViewportSize({ width, height: 1100 });
await openProduction(page, 'images', 'dark');
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
await expect(position(page)).toContainText('1 of ');
}
async function movesBothWays(page) {
await page.keyboard.press('ArrowRight');
await expect(position(page)).toContainText('2 of ');
await page.keyboard.press('ArrowLeft');
await expect(position(page)).toContainText('1 of ');
}
async function staysPut(page) {
const before = await position(page).textContent();
await page.keyboard.press('ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
}
test('the arrows move through the files as soon as the viewer opens, with focus on ✕', async ({ page }) => {
await openImages(page);
await expect(viewer(page).locator('#viewer-close')).toBeFocused();
await movesBothWays(page);
});
test('the arrows keep working from every header control and when focus falls to the page', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-mode').click();
await movesBothWays(page);
await viewer(page).locator('#viewer-mode').click();
// Header text is not focusable: the click leaves focus outside any control.
await viewer(page).locator('.dialog-header p').click();
await movesBothWays(page);
await page.evaluate(() => document.activeElement?.blur());
await movesBothWays(page);
});
test('the arrows move from a details button but stay with focused text and with modifier keys', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-details button').first().focus();
await movesBothWays(page);
await viewer(page).locator('#viewer-details p.prompt-text').first().focus();
await staysPut(page);
await viewer(page).locator('#viewer-close').focus();
const before = await position(page).textContent();
await page.keyboard.press('Shift+ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
});
test('⌘ lists the shortcuts; Esc closes only the overlay and the arrows wait for it', async ({ page }) => {
await openImages(page);
await expect(keysButton(page)).toHaveAccessibleName('Keyboard shortcuts');
await expect(keysButton(page)).toHaveAttribute('title', 'Keyboard shortcuts');
await expect(keysButton(page).locator('svg.icon')).toHaveCount(1);
await expect(page.locator('#viewer-keys-dialog')).toHaveCount(0);
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
// A small dialog sized to its content, its title inset from the edge.
const box = await overlay(page).evaluate(node => {
const frame = node.getBoundingClientRect(), title = node.querySelector('h2').getBoundingClientRect();
return { height: frame.height, inset: title.left - frame.left };
});
expect(box.height).toBeLessThan(700);
expect(box.inset).toBeGreaterThan(8);
await staysPut(page);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await movesBothWays(page);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
});
test('the overlay × closes only the overlay; closing the viewer never leaves one behind', async ({ page }) => {
await openImages(page);
await keysButton(page).click();
await overlay(page).getByRole('button', { name: 'Close keyboard shortcuts', exact: true }).click();
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.evaluate(() => { location.hash = '/overview'; });
await expect(page.locator('#viewer-dialog,#viewer-keys-dialog')).toHaveCount(0);
});
test('opening the shortcuts stops the slideshow', async ({ page }) => {
await openImages(page);
const play = viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
await play.click();
await expect(play).toHaveAttribute('aria-label', 'Pause slideshow');
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(play).toHaveAttribute('aria-label', 'Play slideshow');
});
test('the shortcuts button hides at phone width, like the view switch', async ({ page }) => {
await openImages(page, 600);
await expect(keysButton(page)).toBeHidden();
await movesBothWays(page);
});
// ── #532: the toolbar's actions on single keys. Each key clicks the toolbar's own control, so the two cannot disagree.
const stage = page => viewer(page).locator('#viewer-stage');
const image = page => viewer(page).locator('.viewer-canvas img');
const control = (page, name) => viewer(page).getByRole('button', { name, exact: true });
const transform = page => image(page).evaluate(node => node.style.transform + '|' + node.style.width);
const playing = page => viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
const review = page => page.locator('dialog#modal[open]');
/** A key event a browser would keep for itself, or that repeats, sent straight to what has focus. */
const synth = (page, key, init = {}) => page.evaluate(([key, init]) => {
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}, [key, init]);
async function ready(page) {
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'false');
// Zoom opens as the image is viewed; the viewer's first fit to the stage follows a frame later.
let previous;
await expect.poll(async () => {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const settled = await transform(page);
const same = settled === previous;
previous = settled;
return same;
}).toBe(true);
await stage(page).focus();
}
/** Nothing the keys can change has changed. */
const snapshot = async page => ({ position: await position(page).textContent(), transform: await transform(page),
play: await playing(page).getAttribute('aria-label'), classes: await viewer(page).getAttribute('class'),
details: await viewer(page).locator('#viewer-details').isVisible(), modals: await page.locator('dialog[open]').count() });
test('+ - 0 zoom and fit, R rotates, H flips', async ({ page }) => {
await openImages(page);await ready(page);
const fit = await transform(page);
await page.keyboard.press('+');
await expect.poll(() => transform(page)).not.toBe(fit);
const zoomed = await transform(page);
await page.keyboard.press('-');
await expect.poll(() => transform(page)).not.toBe(zoomed);
await page.keyboard.press('+');await page.keyboard.press('0');
await expect.poll(() => transform(page)).toBe(fit);
await page.keyboard.press('r');
await expect.poll(() => transform(page)).toContain('rotate(90deg)');
await page.keyboard.press('Shift+R');
await expect.poll(() => transform(page)).toContain('rotate(180deg)');
await page.keyboard.press('h');
await expect.poll(() => transform(page)).toContain('scaleX(-1)');
});
test('Space plays and pauses on the stage, and activates a focused button instead', async ({ page }) => {
await openImages(page);await ready(page);
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Pause slideshow');
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await control(page, 'Next media').focus();
await page.keyboard.press('Space');
await expect(position(page)).toContainText('2 of ');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
});
test('F switches the view, I shows and hides the details the toggle offers', async ({ page }) => {
await openImages(page);await ready(page);
const details = viewer(page).locator('#viewer-details');
// Centered without the collapse setting: the toolbar has no toggle, so I does nothing.
await page.keyboard.press('i');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('i');
await expect(details).toBeHidden();
await page.keyboard.press('i');
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
test('F does nothing where the view switch is hidden; ? still opens the shortcuts, which fit at 390 px', async ({ page }) => {
await openImages(page, 390);await ready(page);
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
const fit = await overlay(page).evaluate(node => {
const box = node.getBoundingClientRect();
return { inside: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
noSideScroll: node.scrollWidth <= node.clientWidth };
});
expect(fit).toEqual({ inside: true, noSideScroll: true });
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
await overlay(page).locator('dt').last().scrollIntoViewIfNeeded();
await expect(overlay(page).locator('dt').last()).toBeInViewport();
});
test('D downloads the original', async ({ page }) => {
await openImages(page);await ready(page);
const [download] = await Promise.all([page.waitForEvent('download'), page.keyboard.press('d')]);
expect(download.suggestedFilename()).toMatch(/\.(png|jpe?g|webp)$/);
});
test('Delete opens the review and never deletes; every viewer key waits while the review is open', async ({ page }) => {
const writes = [];
page.on('request', request => { if (request.method() !== 'GET' && !request.url().includes('/__test__/')) writes.push(request.url()); });
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
writes.length = 0;
await page.keyboard.press('Delete');
await expect(review(page)).toBeVisible();
// The review reads its preview; repeating the key must not ask again.
await page.waitForLoadState('networkidle');
const opened = writes.length;
for (const key of ['Delete', 'r', 'f', 'i', 'd', '?']) await page.keyboard.press(key);
// With focus fallen to the page, too.
await page.evaluate(() => document.activeElement?.blur());
for (const key of ['Delete', 'ArrowRight', 'r', ' ']) await page.keyboard.press(key === ' ' ? 'Space' : key);
await page.waitForLoadState('networkidle');
expect(writes.length).toBe(opened);
// Inside the review the arrows and Space are its own: they may choose a mode, which only reads a new preview.
await review(page).getByRole('radio').first().focus();
for (const key of ['ArrowRight', 'Space']) await page.keyboard.press(key);
await expect(overlay(page)).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
expect(writes.filter(url => /delete|trash/i.test(url) && !url.endsWith('/delete/preview'))).toEqual([]);
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
});
test('the keys do nothing with Ctrl, Cmd or Alt held, on a repeat, or while the overlay is open', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
for (const modifier of ['ctrlKey', 'metaKey', 'altKey'])
for (const key of ['+', '-', '0', 'r', 'h', ' ', 'f', 'd', 'Delete', '?']) await synth(page, key, { [modifier]: true });
for (const key of [' ', 'r', 'h', 'f', 'd', 'Delete', '?']) await synth(page, key, { repeat: true });
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
for (const key of ['r', 'h', 'f', 'i', '0', 'Delete']) await page.keyboard.press(key);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
expect(await snapshot(page)).toEqual(before);
await expect(viewer(page)).toBeVisible();
});
test('the keys stay out of a form field, raw metadata and a disclosure', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
await viewer(page).locator('#viewer-details').evaluate(node => {
const field = document.createElement('input');field.id = 'probe';node.append(field);
});
await viewer(page).locator('#probe').focus();
await page.keyboard.type('rhfid0+-? ');
await expect(viewer(page).locator('#probe')).toHaveValue('rhfid0+-? ');
await viewer(page).locator('#probe').evaluate(node => node.remove());
const summary = viewer(page).getByText('Workflow & raw metadata', { exact: true });
await summary.focus();
for (const key of ['r', 'f', '0']) await page.keyboard.press(key);
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
});
test('on a video the image keys and Space do nothing, as the toolbar disables them; a focused video keeps its keys', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto('/prompt_manager/workspace#/images');
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
const video = viewer(page).locator('video');
await expect(video).toHaveJSProperty('controls', true);
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'true');
const errors = [];page.on('pageerror', error => errors.push(error.message));
await stage(page).focus();
for (const key of ['+', '-', '0', 'r', 'h', 'Space']) await page.keyboard.press(key);
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await expect(viewer(page).locator('.viewer-canvas')).toHaveCount(0);
expect(errors).toEqual([]);
await video.focus();
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
+1 -1
View File
@@ -32,7 +32,7 @@ const frame = page => viewer(page).evaluate(node => {
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
await openImages(page);
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
expect(header).toEqual(['div', 'viewer-keys', 'viewer-mode', 'viewer-close']);
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
+12 -8
View File
@@ -1,6 +1,7 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {mapConceptLayout} from './concept-inventory.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs';
@@ -134,7 +135,9 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await rows(page).last().getByRole('button',{name:'Rename / merge',exact:true}).evaluate(button=>button.focus({preventScroll:true}));
await page.keyboard.press('Tab');
await expect(loadMore(page)).toBeFocused();
// Focus scrolled the end of the list into view, which appends the next page.
// D183: Load more sits below the tag window, so reaching it does not move the window's end into view.
await expect(rows(page)).toHaveCount(100);
await page.keyboard.press('Enter');
await expect(rows(page)).toHaveCount(200);
await expect(loadMore(page)).toBeFocused();
await page.keyboard.press('Enter');
@@ -142,11 +145,12 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
// Reload at the top so restored scroll does not append before the check.
await page.evaluate(()=>window.scrollTo(0,0));
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
await page.reload();
await expect(rows(page)).toHaveCount(100);
const scroll=top=>page.evaluate(y=>new Promise(resolve=>{window.scrollTo(0,y ?? document.documentElement.scrollHeight);requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
// D183: the end of the list arrives inside the tag window.
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
for(const count of [200,230]) {
// Each new arrival of the end of the list appends one page.
await scroll(0);await scroll(null);
@@ -206,12 +210,12 @@ test('route disposal aborts requests and removes observers',async({page})=>{
await page.route('**/prompt_manager/vocabulary/**',route=>{held.push(route);});
await page.goto(route);
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).length)).toBe(2);
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).map(item=>item.disconnected))).toEqual([true,true]);
await expect(main(page).locator('.two-col')).toHaveCount(0);
expect(await page.evaluate(()=>document.getElementById('workspace-main').classList.contains('vocabulary-main'))).toBe(false);
await page.unroute('**/prompt_manager/vocabulary/**');
@@ -775,7 +779,7 @@ const SIZES=[[1600,1100],[390,844]];
const MOTIONS=['no-preference','reduce'];
async function openConceptTags(page,theme,motion) {
await page.emulateMedia({reducedMotion:motion});
await page.goto('about:blank');await page.goto(`${concept}#tags`);
await page.goto('about:blank');await page.goto(`${concept}#tags`);await mapConceptLayout(page);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await expect(page.locator('#main h1')).toHaveText('Tags & categories');
}
@@ -791,7 +795,7 @@ async function conceptToolbar(page,theme) {
await page.goto('about:blank');await page.goto(`${concept}#library`);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
const inventory=await page.locator('#main search.toolbar').evaluate(toolbarInventoryOf);
await page.goto('about:blank');await page.goto(`${concept}#tags`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.goto('about:blank');await page.goto(`${concept}#tags`);await mapConceptLayout(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
return inventory;
}
const tagToolbar=page=>page.locator('#workspace-main .two-col > section.surface:first-child > search.toolbar').evaluate(toolbarInventoryOf);
+165 -33
View File
@@ -11,12 +11,12 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole
const sidebarLinks = page => page.locator('aside.sidebar nav a');
const routes = [
['overview', 'Overview', null],
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
// D29: Images no longer links the legacy gallery.
['images', 'Images', null],
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
// #249 E3: the shelf no longer links the legacy Library.
['library', 'Prompt library', null],
['tags', 'Tags & categories', null],
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
// #249 local Inspect: the native Inspect a file page.
['inspect', 'Inspect a file', null],
// #249 System S2: native System; every section native since S12 (D94 retired, asserted below).
['system', 'System', null],
@@ -60,6 +60,10 @@ test.afterEach(async ({ page, browserName }) => {
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
const before = await (await request.get('/prompt_manager/stats')).json();
const total = async path => (await (await request.get(`/prompt_manager/${path}`)).json()).total;
const counts = [['Prompts', await total('library?limit=1')],
['Images', await total('images/output?mode=workspace&limit=1')],
['Tags', await total('vocabulary/tags?limit=1')]];
const mutations = [];
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
@@ -69,10 +73,11 @@ test('workspace starts with real persisted statistics without mutating data', as
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
for (const [label, count] of counts) await expect(statistic(page, label)).toHaveText(String(count));
// #547: a card and the sidebar badge of the page it names show one number.
for (const [route, count] of [['library', counts[0][1]], ['images', counts[1][1]], ['tags', counts[2][1]]]) {
await expect(page.locator(`aside.sidebar nav [data-route="${route}"] .nav-count`)).toHaveText(String(count));
}
expect(before.stats.total_prompts).toBe(2);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
expect(mutations).toEqual([]);
await expect(page.locator('iframe')).toHaveCount(0);
@@ -93,20 +98,106 @@ test('all shell destinations deep-link, reload and expose working existing appli
await page.reload();
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
}
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
// #525: Overview's cards open the native pages, and no workspace page links a legacy one.
for (const [action, hash, title] of [
['Open Images', '#/images', 'Images'],
['Open Prompt library', '#/library', 'Prompt library'],
['Open Tags & categories', '#/tags', 'Tags & categories'],
['Open Inspect a file', '#/inspect', 'Inspect a file'],
['Open System', '#/system/overview', 'System'],
]) {
await page.goto(`${workspace}#/overview`);
const main = page.locator('#workspace-main');
const card = main.getByRole('link', { name: action, exact: true });
await expect(card).toHaveAttribute('href', hash);
await card.click();
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace${hash}$`));
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
}
await page.goto(`${workspace}#/overview`);
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
await expect(await request.get('/prompt_manager/admin')).toBeOK();
// The legacy metadata page stays reachable from Overview beside the native Inspect page.
const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true });
await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html');
await expect(await request.get('/prompt_manager/gallery.html')).toBeOK();
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
await gallery.click();
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
await expect(page.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true })).toBeVisible();
await expect(page.locator('a[href*="/prompt_manager/admin"], a[href*="/prompt_manager/gallery"]')).toHaveCount(0);
await expect(page.locator('#workspace-main').getByText('Your existing tools remain available')).toHaveCount(0);
// The legacy pages themselves stay at their URLs.
for (const legacy of ['/prompt_manager/admin', '/prompt_manager/gallery', '/prompt_manager/gallery.html']) {
await expect(await request.get(legacy)).toBeOK();
}
});
test('/prompt_manager/web lands on the workspace Library and Back does not bounce through it (#525)', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
await page.evaluate(() => { location.href = '/prompt_manager/web'; });
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/overview$/);
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
});
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
await page.goto(`${workspace}#/system/overview`);
const line = page.locator('#workspace-main .legacy-pages');
await expect(line).toBeVisible();
await expect(line).toContainText('Legacy pages');
const links = line.getByRole('link');
await expect(links).toHaveCount(4);
for (const [name, url, ready] of [
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
['Gallery', '/prompt_manager/gallery', 'body'],
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
]) {
const opened = context.waitForEvent('page');
await line.getByRole('link', { name, exact: true }).click();
const tab = await opened;
await tab.waitForLoadState('domcontentloaded');
const target = new URL(tab.url());
expect(target.pathname + target.hash).toBe(url);
await expect(tab.locator(ready).first()).toBeAttached();
await tab.close();
}
// Only on System's Overview section, and gone with the page.
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(line).toBeHidden();
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
});
const bootFailure = page => page.locator('#workspace-boot-failure');
test('a healthy workspace never shows the load failure message (#525)', async ({ page }) => {
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.waitForFunction(() => document.readyState === 'complete');
await expect(bootFailure(page)).toBeHidden();
});
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
await page.route(pattern, route => route.abort());
await page.goto(`${workspace}#/library`);
const failure = bootFailure(page);
await expect(failure).toBeVisible();
await expect(failure).toHaveAttribute('role', 'alert');
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
await expect(dashboard).toBeVisible();
await expect(failure.getByRole('link')).toHaveCount(4);
await dashboard.click();
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
expect(pageErrors).toEqual([]);
});
}
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
await page.goto(`${workspace}#/library`);
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
// The broken module's own error is this test's premise, not a failure of the page.
page.workspaceErrors.length = 0;
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
@@ -287,25 +378,65 @@ test('unavailable host palette falls back to the system theme and tracks system
});
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
const sources = ['**/prompt_manager/library?limit=1', '**/prompt_manager/images/output?mode=workspace&limit=1',
'**/prompt_manager/vocabulary/tags?limit=1'];
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
for (const source of sources) {
await page.route(source, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
}
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.unroute('**/prompt_manager/stats');
await page.route('**/prompt_manager/stats', route => route.fulfill({
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
}));
for (const source of sources) {
await page.unroute(source);
await page.route(source, route => route.fulfill({ json: { success: true, total: 0, items: [], images: [], tags: [] } }));
}
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
for (const label of ['Prompts', 'Images', 'Tags']) {
await expect(statistic(page, label)).toHaveText('0');
}
await expect(page.locator('#workspace-main .empty-state')).toBeVisible();
});
test('a slow or failed Images read leaves the other Overview cards and retries only itself', async ({ page }) => {
const images = '**/prompt_manager/images/output?mode=workspace&limit=1';
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route(images, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
await expect(statistic(page, 'Images')).toHaveText('–');
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await expect(statistic(page, 'Prompts')).toHaveText('2');
await page.unroute(images);
let libraryReads = 0;
page.on('request', request => { if (new URL(request.url()).pathname === '/prompt_manager/library') libraryReads++; });
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(libraryReads).toBe(0);
});
test('opening Overview reads each count once beside the sidebar', async ({ page }) => {
let reads = 0;
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === null) reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(reads).toBe(1);
});
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
@@ -324,11 +455,9 @@ test('leaving a loading overview prevents its delayed result from replacing the
let completed;
const pending = new Promise(resolve => { release = resolve; });
const finished = new Promise(resolve => { completed = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await page.route('**/prompt_manager/library?limit=1', async route => {
await pending;
await route.fulfill({ json: {
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
} }).catch(() => {});
await route.fulfill({ json: { success: true, total: 999, items: [] } }).catch(() => {});
completed();
});
await page.goto(`${workspace}#/overview`);
@@ -351,7 +480,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
let reads = 0;
page.on('request', request => {
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
if (new URL(request.url()).pathname === '/prompt_manager/vocabulary/tags') reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
@@ -428,6 +557,8 @@ const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Int
'Backup & export', 'Diagnostics & logs'];
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
test.describe(() => {
test.use({ olderImagesNotice: true });
test('System is native: the concept shell, section navigation, sections and the Save row', async ({ page }) => {
const mutations = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
@@ -438,7 +569,7 @@ test('System is native: the concept shell, section navigation, sections and the
// D92: no "Sample configuration" chip.
await expect(main.locator('.heading .chip')).toHaveCount(0);
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
const nav = main.getByRole('group', { name: 'System sections', exact: true });
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
@@ -506,6 +637,7 @@ test('System is native: the concept shell, section navigation, sections and the
await expect(main.locator('.settings-layout')).toHaveCount(1);
expect(mutations).toEqual([]);
});
});
test('Overview opens native System from its System card (Q12)', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
+42 -1
View File
@@ -19,6 +19,10 @@ after the plain ones in this order:
recorder stores ``limited`` (more than 16 candidates, so none are stored).
An A1111 block with two negative lines is not queued any more: it offers
no candidate to choose (#284).
- ``unreadable`` (#536): an item whose stored candidates are all
``unresolved``, as items recorded before the choice rule are, written directly
(the recorder no longer stores one). The Library holds "Golden dunes at
dusk" with its negative, and "Quiet harbor" with two.
- ``unavailable``: a file recorded under the ``temp`` directory while it was a
gallery root; the root is then removed again, so no current root lists it.
@@ -41,6 +45,7 @@ page closed is dropped at such a write.
from __future__ import annotations
import hashlib
import json
import os
import threading
@@ -58,10 +63,11 @@ if TYPE_CHECKING:
from py.api import PromptManagerAPI
POSITIVES = ("Golden dunes at dusk", "Harbor lights at night")
UNREADABLE_REASON = "Unsupported latent ancestry"
NEGATIVE = "Soft negative, blur"
# The prompt the conflict item's file is linked to after it was queued.
FALLBACK = "Misty forest path"
SPECIAL = ("conflict", "limited", "unavailable")
SPECIAL = ("conflict", "limited", "unavailable", "unreadable")
# The concept's first three images (concept app.js `images`) and first prompt.
CONCEPT_IMAGES = (
("alpine_00001.png", (1536, 1152)),
@@ -215,6 +221,38 @@ def _unavailable(api: PromptManagerAPI, root: Path, path: Path) -> None:
GalleryConfig.MONITORING_DIRECTORIES = roots
def _unreadable(api: PromptManagerAPI, path: Path) -> None:
"""Queue ``path`` with candidates no reviewer can apply, and Library prompts."""
facts = path.stat()
spelled = os.path.join(os.path.realpath(path.parent), path.name)
candidates = [
{
"hash": hashlib.sha256(f"unreadable-{sampler}".encode()).hexdigest(),
"sampler_id": sampler,
"state": "unresolved",
"reason": UNREADABLE_REASON,
"positive": None,
"negative": None,
}
for sampler in ("11", "13")
]
api.db.record_review_item(
locator=os.path.normcase(spelled),
spelled_path=spelled,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
size=facts.st_size,
mtime_ns=facts.st_mtime_ns,
epoch=api.db.get_execution_epoch(),
resolver_version=1,
evidence={"reason": "", "resolver_version": 1, "candidates": candidates},
candidate_count=len(candidates),
limited=False,
)
api.db.save_prompt_pair(POSITIVES[0], NEGATIVE)
api.db.save_prompt_pair("Quiet harbor", "grainy")
api.db.save_prompt_pair("Quiet harbor", "washed out")
def seed_review(
api: PromptManagerAPI,
root: Path,
@@ -254,6 +292,9 @@ def seed_review(
path = (root / "temp" if kind == "unavailable" else output) / relative
if kind == "conflict":
_linked_after_queueing(api, path)
elif kind == "unreadable":
_png(path, (70, 40, 120))
_unreadable(api, path)
elif kind == "limited":
many = tuple(f"{POSITIVES[0]} {n}" for n in range(LIMITED_SAMPLERS))
_png(path, (90, 90, 90), graph=_graph(positives=many))
+3 -3
View File
@@ -210,14 +210,14 @@ test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page },
}
});
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
test(`${label} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
await addEditor(page, type);
const popupPromise = page.waitForEvent('popup');
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
+3 -2
View File
@@ -55,9 +55,10 @@ test('paired default layout uses equal editors and follows real host themes',asy
}
});
test('paired explicit Save persists an empty negative through actual API and preserves local draft',async({page,request},testInfo)=>{
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByRole('button',{name:'Save pair',exact:true}).click();
test('paired explicit Save persists an empty negative through actual API, gives a new prompt its metadata and preserves local draft',async({page,request},testInfo)=>{
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByText('Metadata · category / tags',{exact:true}).click();await root.getByRole('combobox',{name:'Category',exact:true}).fill('Saved category');await root.getByRole('combobox',{name:'Tags',exact:true}).fill('saved-tag');await root.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();const response=await request.get(`/prompt_manager/pairs?text=${encodeURIComponent(text)}`);expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:text,negative_text:''})]));expect(await values(page,id)).toMatchObject({text,negative_text:''});
const library=await (await request.get(`/prompt_manager/library?text=${encodeURIComponent(text)}&limit=10`)).json();expect(library.items).toEqual(expect.arrayContaining([expect.objectContaining({category:'Saved category',tags:['saved-tag']})]));
});
test('paired library filters search both texts, persist on reload and keep editor metadata separate',async({page,request},testInfo)=>{
+63 -3
View File
@@ -84,7 +84,7 @@ test('all node schemas and extension entrypoints load from the real host', async
});
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
test(`${type} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
@@ -95,14 +95,74 @@ for (const type of ['PromptManager', 'PromptManagerText']) {
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
}
test('top-bar PM button opens the workspace in either renderer', async ({ page }) => {
const button = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
await expect(button).toBeVisible();
const popupPromise = page.waitForEvent('popup');
await button.click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.close();
});
for (const [type, buttonName] of [['PromptManager', '🌐 Web UI'], ['PromptManagerFocused', 'Web UI ↗'], ['PromptManagerPair', 'Web UI ↗']]) {
test(`${type} and the PM button open the workspace after switching renderers both ways, a workflow reload and node removal`, async ({ page }, testInfo) => {
const id = await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
node.setSize([900, 800]);
app.canvas.centerOnNode(node);
return node.id;
}, type);
const webUi = page.getByRole('button', { name: buttonName, exact: true });
const pm = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
const opensWorkspace = async button => {
await expect(button).toHaveCount(1);
await expect(button).toBeVisible();
const popupPromise = page.waitForEvent('popup');
await button.click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
await popup.close();
};
await opensWorkspace(webUi);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await opensWorkspace(webUi);
await opensWorkspace(pm);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(app.graph.serialize());
});
await opensWorkspace(webUi);
await opensWorkspace(pm);
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(webUi).toHaveCount(0);
await opensWorkspace(pm);
});
}
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
+5 -5
View File
@@ -8,7 +8,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
} });
expect(saved.ok()).toBe(true);
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
const workspace = await context.newPage();
const errors = [];
workspace.on('pageerror', error => errors.push(error.message));
@@ -16,7 +16,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await workspace.goto('/prompt_manager/workspace#/overview');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(stats.total_prompts.toLocaleString());
.toHaveText(library.total.toLocaleString());
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
const imageResponse = workspace.waitForResponse(response => {
@@ -37,11 +37,11 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
}
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
// D29: Images no longer links the legacy gallery; #525: nor does Overview. The page stays at its URL.
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
await workspace.evaluate(() => { location.hash = '/overview'; });
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
const imagesCard = workspace.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true });
await expect(imagesCard).toHaveAttribute('href', '#/images');
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
await workspace.evaluate(() => { location.hash = '/images'; });
await workspace.reload();
+1
View File
@@ -154,6 +154,7 @@
"POST /prompt_manager/restore/apply",
"POST /prompt_manager/restore/preview",
"POST /prompt_manager/review/items/{item_id}/decision",
"POST /prompt_manager/review/items/{item_id}/link",
"POST /prompt_manager/save",
"POST /prompt_manager/saved-views",
"POST /prompt_manager/scan",
+3
View File
@@ -21,11 +21,14 @@ export function mountedDom() {
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
focus(){this.focused=true;}
click(){this.fire('click');}
getClientRects(){return this.isConnected&&!this.layoutHidden?[{}]:[];}
showModal(){this.open=true;}
close(){this.open=false;this.fire('close');}
contains(node){return node===this||this.children.some(c=>c.contains(node));}
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
querySelectorAll(selector){return walk(this,n=>n.tagName===selector);}
pause(){this.paused=true;}
load(){this.loaded=true;}
play(){this.played=true;return Promise.resolve();}
+1 -1
View File
@@ -13,7 +13,7 @@ function dom() {
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
click(target){this.listeners.get('click')?.({target});}
}
const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const previous=global.document,toast=new Element('div');global.document={querySelectorAll:()=>[],createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
return {Element,walk,toast,restore:()=>{global.document=previous;}};
}
+34
View File
@@ -0,0 +1,34 @@
// #525: where the canvas buttons open PromptManager (web/js/workspace-entry.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = import('../../web/js/workspace-entry.js');
const state = import('../../web/js/workspace/state.mjs');
test('the canvas entry route is the workspace Library, a native route', async () => {
const { WORKSPACE_ENTRY_ROUTE } = await source;
const { resolveRoute } = await state;
assert.equal(WORKSPACE_ENTRY_ROUTE, '/prompt_manager/workspace#/library');
const [pathname, hash] = WORKSPACE_ENTRY_ROUTE.split('#');
assert.equal(pathname, '/prompt_manager/workspace');
assert.equal(resolveRoute(`#${hash}`).id, 'library');
});
test('/prompt_manager/web redirects to the same route, under any mount prefix', async () => {
const { WORKSPACE_ENTRY_ROUTE } = await source;
const html = fs.readFileSync(path.join(__dirname, '../../web/index.html'), 'utf8');
const [, pattern, flags, replacement] = html.match(/location\.replace\(\s*window\.location\.pathname\.replace\(\/(.+)\/([a-z]*), '([^']+)'\)/);
const redirect = pathname => pathname.replace(new RegExp(pattern, flags), replacement);
assert.equal(redirect('/prompt_manager/web'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/prompt_manager/web/'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/prompt_manager/index.html'), WORKSPACE_ENTRY_ROUTE);
assert.equal(redirect('/sub/prompt_manager/web'), `/sub${WORKSPACE_ENTRY_ROUTE}`);
});
test('no canvas entry point still names a legacy page', () => {
for (const file of ['web/comfy/prompt_manager.js', 'web/js/focused-node.js', 'web/js/classic-node.js']) {
const code = fs.readFileSync(path.join(__dirname, '../..', file), 'utf8');
assert.doesNotMatch(code, /\/prompt_manager\/(?:admin|gallery|web)\b/, file);
}
});
+3 -3
View File
@@ -79,9 +79,9 @@ test('Show in folder needs the full listing identity (D38)',async()=>{
});
test('Show in folder outcomes use the D38 toasts',async()=>{
const {revealOutcome}=await load();
assert.equal(revealOutcome(null),'Opened the folder on this computer.');
assert.equal(revealOutcome({status:403,code:'not-local'}),'Show in folder works only in a browser on the computer running ComfyUI.');
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on this computer.');
assert.equal(revealOutcome(null),'Opened the folder on the computer running ComfyUI.');
assert.equal(revealOutcome({status:403,code:'not-same-origin'}),'The folder was not opened: this page\'s address does not match the server\'s.');
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on the computer running ComfyUI.');
assert.equal(revealOutcome({status:429,code:'busy'}),'The folder is already opening.');
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
@@ -69,12 +69,16 @@ function fakeDOM() {
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
getElementById: id => (id === 'workspace-toast' ? toast : null),
querySelectorAll: () => [],
};
return { Element, body, toast, restore() { globalThis.document = previous; } };
}
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
function mountBar(dom, items, checkedKeys, options = {}) {
// The bar publishes its height for the sticky chrome (D186); nothing in this DOM ever resizes.
globalThis.ResizeObserver ??= class { observe() {} disconnect() {} };
document.documentElement ??= { style: { setProperty() {}, removeProperty() {} } };
const scope = createScope();
const grid = new dom.Element('div');
const focusFallback = new dom.Element('h1');
+101 -1
View File
@@ -157,6 +157,27 @@ test('Viewer.js is built only on a loaded image and an unbuilt viewer is release
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
test('a pending Viewer whose element left the stage is still destroyed, without an error', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
const instances = [];
// Viewer.js's destroy() ends with `this.viewer.parentNode.removeChild(this.viewer)`, and moving on empties the stage.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = true; this.viewer = document.createElement('div'); instances.push(this); }
destroy() { if (!this.viewer.parent) throw new TypeError('null is not an object'); this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
const [loading] = instances, removed = [];
loading.node.removeAttribute = key => removed.push(key);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
assert.deepEqual(removed, ['src'], 'and its image is released');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
@@ -200,7 +221,9 @@ test('viewer renders the concept shell and toolbar order', async () => {
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
const [header,layout] = dialog.children;
assert.equal(header.className,'dialog-header');
const [heading,mode,close] = header.children, [title,subtitle] = heading.children;
const [heading,keys,mode,close] = header.children, [title,subtitle] = heading.children;
// #532 D170: ⌘ before the view switch opens the shortcuts.
assert.deepEqual([keys.tagName,keys.id,keys.className,keys.attributes['aria-label'],keys.title],['button','viewer-keys','icon-btn','Keyboard shortcuts','Keyboard shortcuts']);
// #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default).
assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']);
mode.fire('click');
@@ -233,6 +256,83 @@ test('viewer renders the concept shell and toolbar order', async () => {
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('⌘ opens the shortcuts on top of the viewer and closing them leaves it open (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, [keys] = f.walk(dialog,n => n.id === 'viewer-keys');
const position = () => f.walk(dialog,n => n.attributes.role === 'status')[0].textContent;
const overlays = () => f.walk(dialog,n => n.id === 'viewer-keys-dialog');
assert.equal(overlays().length,0,'built on demand');
assert.match(position(),/^1 of 2/);
f.doc.fire('keydown',{key:'ArrowRight',target:f.walk(dialog,n => n.id === 'viewer-close')[0]});
assert.match(position(),/^2 of 2/,'the arrows work with focus on ×');
keys.fire('click');
const [overlay] = overlays();
assert.equal(overlay.open,true);
assert.equal(overlay.attributes['aria-labelledby'],'viewer-keys-title');
assert.deepEqual(f.walk(overlay,n => n.tagName === 'kbd').map(n => n.textContent),['←','→','Space','+','-','0','R','H','F','I','?','Esc','D']);
f.doc.fire('keydown',{key:'ArrowLeft',target:keys});
assert.match(position(),/^2 of 2/,'the arrows wait for the overlay');
f.walk(overlay,n => n.attributes['aria-label'] === 'Close keyboard shortcuts')[0].fire('click');
assert.equal(overlays().length,0);
assert.equal(dialog.open,true,'the viewer stays open');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('hotkeys click the toolbar\'s own controls, so an unavailable control does nothing (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')], deletes = [];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onDelete:(entry,button) => deletes.push([entry,button])});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, by = (test) => f.walk(dialog,test)[0];
const stage = by(n => n.id === 'viewer-stage'), mode = by(n => n.id === 'viewer-mode'), remove = by(n => n.textContent === 'Delete…');
const press = (key,init = {}) => { let prevented = false; f.doc.fire('keydown',{key,target:stage,preventDefault(){prevented = true;},...init});return prevented; };
// No media is ready: every transform is aria-disabled, so its key is swallowed by the same check as a click.
for (const key of ['+','=','-','0','r','H']) assert.equal(press(key),true,key);
// Modifiers, repeats and composition leave every key alone; Shift does not.
for (const init of [{ctrlKey:true},{metaKey:true},{altKey:true},{repeat:true},{isComposing:true}]) assert.equal(press('f',init),false);
assert.equal(press('+',{repeat:true}),true,'zoom may repeat');
assert.equal(press('Unbound'),false);
// F clicks the switch, and does nothing while the switch is hidden.
assert.equal(mode.attributes['aria-label'],'Full screen view');
press('F',{shiftKey:true});assert.equal(mode.attributes['aria-label'],'Centered view');
mode.layoutHidden = true;assert.equal(press('f'),false);assert.equal(mode.attributes['aria-label'],'Centered view');mode.layoutHidden = false;
// I needs the toggle: it is there in full screen and gone when Centered.
const details = by(n => n.id === 'viewer-details');
press('i');assert.equal(details.hidden,true);press('i');assert.equal(details.hidden,false);
press('f');assert.equal(press('i'),false,'no toggle when Centered');
// Delete only opens the page's review, for this file.
press('Delete');assert.equal(deletes.length,1);assert.equal(deletes[0][1],remove);
press('Delete',{repeat:true});assert.equal(deletes.length,1);
// D goes through the link's own check while the file cannot be downloaded.
controller.publish({loading:false,error:false,record:null,refreshRequired:true,message:'Refresh required.'});
const download = by(n => n.attributes['aria-label'] === 'Download original');
let blocked = false;download.fire('click',{preventDefault(){blocked = true;}});assert.equal(blocked,true);
assert.equal(press('d'),true);
// Space and ? on the stage; a target that keeps its keys leaves them alone.
assert.equal(press(' '),true);
assert.equal(press('?'),true);
const overlay = by(n => n.id === 'viewer-keys-dialog');assert.equal(overlay.open,true);
assert.equal(press('r'),false,'off under the overlay');
overlay.close();
assert.equal(press('r',{target:new f.Node('body')}),false,'a target outside the viewer that is not the page');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
const g = mountedDom(), scope = createScope();
const plain = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
try {
plain.open({items,selectedKey:'c'.repeat(64)});
const stage = g.walk(g.container,n => n.id === 'viewer-stage')[0];
let prevented = false;g.doc.fire('keydown',{key:'Delete',target:stage,preventDefault(){prevented = true;}});
assert.equal(prevented,false,'no Delete… in this viewer, so no Delete key');
g.doc.fire('keydown',{key:'?',target:stage});
assert.ok(!g.walk(g.container,n => n.tagName === 'kbd').some(n => n.textContent === 'Delete'),'and none listed');
} finally { plain.dispose(); scope.dispose(); g.restore(); }
});
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
+102 -24
View File
@@ -128,9 +128,7 @@ test('selecting a card loads detail and option labels, first workable preselecte
['Sampler 9 · No positive / Soft negative', true],
['Sampler 12 · Dunes / Soft negative', false],
['Sampler 28 · Dunes / Harsh negative', false],
['Leave unresolved', false],
]);
assert.equal(c.state.options.at(-1).value, 'unresolved');
assert.equal(c.state.choice, HASH_A);
assert.deepEqual(c.state.prompts, { positive: { status: 'resolved', text: 'golden dunes' }, negative: { status: 'resolved', text: 'blur' } });
c.choose(HASH_C);
@@ -138,16 +136,17 @@ test('selecting a card loads detail and option labels, first workable preselecte
c.choose(HASH_B);
assert.equal(c.state.prompts.negative.text, 'noise');
c.choose('unresolved');
assert.equal(c.state.prompts.positive.status, 'ambiguous');
assert.equal(c.state.choice, HASH_B, 'Leave unresolved is its own action');
await c.select('two');
await settle();
assert.equal(c.state.selectedId, 'two');
assert.deepEqual(c.state.options.map(option => option.label), ['Leave unresolved']);
assert.equal(c.state.choice, 'unresolved');
assert.deepEqual(c.state.options, []);
assert.equal(c.state.choice, null);
assert.equal(c.state.unreadable, null, 'a limited file keeps its own warning');
assert.match(c.state.warning, /too many sampler paths/i);
});
test('apply sends the selected candidate hash or unresolved with a fresh capture', async () => {
test('apply sends the selected candidate hash with a fresh capture and the next item opens', async () => {
const { c, h } = await loaded();
c.choose(HASH_B);
const done = await c.apply();
@@ -157,31 +156,105 @@ test('apply sends the selected candidate hash or unresolved with a fresh capture
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
assert.deepEqual(decision.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
dependency_fingerprint: PRINT, choice: HASH_B });
assert.equal(c.state.phase, 'done');
assert.equal(c.state.total, 1);
assert.equal(c.state.phase, 'idle');
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.toast, 'Association accepted.');
assert.equal(c.state.decisions, 1);
// The server releases a used capture itself.
assert.equal(h.of('DELETE', CAPTURE).length, 0);
const again = await loaded();
again.c.choose('unresolved');
assert.equal(await again.c.apply(), true);
const [unresolved] = again.h.of('POST', `${ITEMS}/one/decision`);
assert.equal(unresolved.body.choice, 'unresolved');
assert.equal(unresolved.body.capture_token, 'capture-1');
assert.equal(again.c.state.toast, 'Left unresolved.');
});
test('an unavailable file can only be left unresolved, without a capture', async () => {
test('leave unresolved is its own action', async () => {
const { c, h } = await loaded();
assert.equal(await c.leaveUnresolved(), true);
const [unresolved] = h.of('POST', `${ITEMS}/one/decision`);
assert.equal(unresolved.body.choice, 'unresolved');
assert.equal(unresolved.body.capture_token, 'capture-1');
assert.equal(c.state.toast, 'Left unresolved.');
assert.equal(c.state.selectedId, 'two');
});
const unreadable = () => detailOf(QUEUE[0], { candidates: [
{ ...candidate(HASH_A, '9', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
{ ...candidate(HASH_B, '12', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
{ ...candidate(HASH_C, '14', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported node' },
] });
test('nothing selectable: the reasons are summed, there is no choice and apply sends nothing', async () => {
const { c, h } = await loaded({ [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
assert.equal(c.state.choice, null);
assert.equal(c.state.prompts, null);
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
['Sampler 9 · No positive / No negative (Unsupported latent ancestry)', true],
['Sampler 12 · No positive / No negative (Unsupported latent ancestry)', true],
['Sampler 14 · No positive / No negative (Unsupported node)', true],
]);
assert.deepEqual(c.state.unreadable, { count: 3, reasons: [{ reason: 'Unsupported latent ancestry', count: 2 }, { reason: 'Unsupported node', count: 1 }] });
assert.equal(await c.apply(), false);
assert.equal(h.of('POST', '/').length, 0);
});
const linkRoute = (body = {}) => ({ [`POST ${ITEMS}/one/link`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
item: { ...QUEUE[0], state: 'selected', revision: 1 }, pair: { id: call.body.pair_id ?? 7, image_id: 3, status: 'added' }, ...body }) });
test('link posts the chosen prompt and recipe with a fresh capture, then the next item opens', async () => {
const { c, h } = await loaded({ ...linkRoute(), [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
const [link] = h.of('POST', `${ITEMS}/one/link`);
assert.deepEqual(link.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
dependency_fingerprint: PRINT, positive_id: 4, pair_id: 9 });
assert.equal(c.state.toast, 'Linked to Harbor.');
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.decisions, 1);
});
test('a refused link keeps the item, releases the capture and names the reason', async () => {
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => fail(409, 'association-conflict', 'This image is already linked') });
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: null, title: 'Harbor' }), false);
assert.equal(c.state.selectedId, 'one');
assert.equal(c.state.warning, 'This image is already linked');
assert.equal(c.state.decisions, 0);
assert.equal(h.of('DELETE', CAPTURE).length, 1);
});
test('an unanswered link resends the same body once; another choice takes a new capture', async () => {
let answers = 0;
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => {
answers += 1;
return answers === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, total: 1 });
} });
assert.equal(await c.link({ itemId: 'two', positiveId: 4, pairId: 9, title: 'Harbor' }), false, 'not the open item');
assert.equal(h.of('POST', OBSERVE).length, 0);
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), false);
assert.match(c.state.warning, /Link image again/);
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
const [first, second] = h.of('POST', `${ITEMS}/one/link`);
assert.deepEqual(first.body, second.body);
assert.equal(h.of('POST', OBSERVE).length, 1);
});
test('the last decision empties the page and a later page still comes back from the re-read', async () => {
let queue = [QUEUE[0]];
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
queue = [];
assert.equal(await c.leaveUnresolved(), true);
await settle();
assert.equal(c.state.selectedId, null);
assert.deepEqual(c.state.items, []);
assert.equal(c.state.warning, '');
});
test('an unavailable file can only be left unresolved, without a capture or a link', async () => {
const gone = item('one', { target: null, unavailable: true, url: null });
const { c, h } = await loaded({
[`GET ${ITEMS}`]: () => response(200, page([gone])),
[`GET ${ITEMS}/one`]: () => response(200, detailOf(gone)),
});
assert.ok(c.state.options.slice(0, -1).every(option => option.disabled));
assert.equal(c.state.choice, 'unresolved');
assert.ok(c.state.options.every(option => option.disabled));
assert.equal(c.state.choice, null);
assert.match(c.state.warning, /not under a current image source/i);
assert.equal(await c.apply(), true);
assert.equal(await c.link({ itemId: 'one', positiveId: 1, pairId: null, title: 'x' }), false);
assert.equal(await c.leaveUnresolved(), true);
assert.equal(h.of('POST', OBSERVE).length, 0);
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
assert.equal(decision.body.capture_token, null);
@@ -275,11 +348,14 @@ test('a review-conflict on a still pending item reads its detail again', async (
assert.equal(h.of('POST', `${ITEMS}/one/decision`).at(-1).body.expected_revision, 1);
});
test('leave for later writes nothing', async () => {
test('leave for later writes nothing, opens the next item and wraps', async () => {
const { c, h } = await loaded();
c.leave();
assert.equal(c.state.phase, 'done');
await c.leave();
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.toast, null);
assert.equal(c.state.decisions, 0);
await c.leave();
assert.equal(c.state.selectedId, 'one');
assert.equal(h.of('POST', '/').length, 0);
assert.equal(h.of('DELETE', '/').length, 0);
});
@@ -781,7 +857,9 @@ test('M5: an unanswered decision that landed finishes as recorded, not as decide
queue = [QUEUE[1]]; // the server did record it: its change arrives
await c.refresh();
await settle();
assert.equal(c.state.phase, 'done');
assert.equal(c.state.phase, 'idle');
assert.equal(c.state.decisions, 1);
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.toast, 'Association accepted.');
assert.doesNotMatch(c.state.warning, /decided elsewhere/);
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the server used the capture');
+46 -16
View File
@@ -17,7 +17,7 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
const tags = resolveRoute('#/tags');
assert.equal(tags.label, 'Tags & categories');
assert.equal(routeTitle(tags), 'Tags');
assert.equal(tags.href, '/prompt_manager/admin#/tags', 'Overview keeps the existing tag manager card');
assert.equal(tags.href, '#/tags', 'Overview opens the native page (#525)');
for (const id of ['images', 'library', 'inspect', 'overview', 'system']) {
const route = resolveRoute(`#/${id}`);
assert.equal(routeTitle(route), route.label);
@@ -48,7 +48,6 @@ test('system resolves its sections; #/overview stays the production hub', async
// Q12: Overview's System card opens the native System Overview, with no legacy href or action.
const card = routes.find(route => route.id === 'system');
assert.equal(card.href, '#/system/overview');
assert.equal(card.native, true);
assert.equal(card.action, 'Open System');
});
@@ -77,9 +76,28 @@ test('inspect is the native Inspect a file page with the concept description', a
// The concept's heading description (app.js renderMetadata 743).
assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.');
assert.equal(isHandoff(inspect), false);
// Overview keeps its card for the legacy metadata page, which stays working.
assert.equal(inspect.href, '/prompt_manager/gallery.html');
assert.equal(inspect.action, 'Open existing metadata inspector');
// Overview's card opens the native page (#525); the legacy metadata page stays at its URL.
assert.equal(inspect.href, '#/inspect');
assert.equal(inspect.action, 'Open Inspect a file');
});
test('every Overview card opens its own native page, never a legacy one (#525)', async () => {
const { resolveRoute, routes } = await source;
const cards = routes.filter(route => route.href);
assert.deepEqual(cards.map(card => card.id), ['images', 'library', 'tags', 'inspect', 'system']);
for (const card of cards) {
assert.match(card.href, /^#\//, card.id);
assert.equal(resolveRoute(card.href).id, card.id);
assert.match(card.action, /^Open /, card.id);
assert.doesNotMatch(card.action, /existing/, card.id);
}
});
test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => {
const { LEGACY_PAGES } = await source;
assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags',
'/prompt_manager/gallery', '/prompt_manager/gallery.html']);
for (const page of LEGACY_PAGES) assert.ok(page.label);
});
test('a job of the Activity page is its own deep link', async () => {
@@ -105,17 +123,29 @@ test('a job of the Activity page is its own deep link', async () => {
dispose();
});
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
['Prompts', 0], ['Images', 12], ['Tags', 3],
]);
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
{total_prompts:'2', total_images:0, total_tags:0},
{total_prompts:Infinity, total_images:0, total_tags:0},
{total_prompts:0, total_images:1.5, total_tags:0}]) {
assert.throws(() => summarizeStats(stats), /statistics/i);
}
test('Overview cards show the count of the page they name', async () => {
const { summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES } = await source;
assert.deepEqual(OVERVIEW_COUNT_ROUTES, ['library', 'images', 'tags']);
const counts = new Map([['library', 64], ['images', 122], ['tags', 306], ['favorites', 8]]);
assert.deepEqual(summarizeOverviewCounts(counts), {
cards: [['Prompts', 64], ['Images', 122], ['Tags', 306]], pending: false, failed: false, empty: false,
});
});
test('Overview tells loading, failed and really empty counts apart', async () => {
const { summarizeOverviewCounts } = await source;
const nothing = summarizeOverviewCounts(new Map());
assert.deepEqual([nothing.pending, nothing.failed, nothing.empty], [true, false, false]);
assert.deepEqual(nothing.cards, [['Prompts', null], ['Images', null], ['Tags', null]]);
// A slow Images read does not hold back the cards already read.
const partial = summarizeOverviewCounts(new Map([['library', 0], ['tags', 0]]));
assert.deepEqual(partial.cards, [['Prompts', 0], ['Images', null], ['Tags', 0]]);
assert.deepEqual([partial.pending, partial.failed, partial.empty], [true, false, false]);
const failed = summarizeOverviewCounts(new Map([['library', 2], ['images', null], ['tags', 3]]));
assert.deepEqual([failed.pending, failed.failed, failed.empty], [false, true, false]);
assert.deepEqual(failed.cards[1], ['Images', null]);
const empty = summarizeOverviewCounts(new Map([['library', 0], ['images', 0], ['tags', 0]]));
assert.deepEqual([empty.pending, empty.failed, empty.empty], [false, false, true]);
});
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
+16
View File
@@ -174,3 +174,19 @@ test('D158: "Save prompts from other workflows" drafts save_foreign_runs, disabl
assert.deepEqual(foreignRunsState({ value: true, monitoring: false, stopping: true }),
{ disabled: false, checked: true, note: FOREIGN_RUNS.off });
});
test('a failed notice write still dismisses for this page and tells watchers (#527)', async () => {
const { noticeDismissed, dismissNotice } = await sourcesModule;
const denied = () => { throw new Error('SecurityError'); };
assert.equal(noticeDismissed(denied), false);
assert.equal(dismissNotice(denied), false);
assert.equal(noticeDismissed(denied), true);
});
test('sourceCount reads the effective sources, null when the answer does not say (#527)', async () => {
const { sourceCount } = await import('../../web/js/workspace/older-images-notice.mjs');
assert.equal(sourceCount({ sources: { effective: [{}, {}] } }), 2);
assert.equal(sourceCount({ sources: { effective: [] } }), 0);
assert.equal(sourceCount({ sources: {} }), null);
assert.equal(sourceCount(null), null);
});
+56 -6
View File
@@ -13,10 +13,11 @@ class FakeToast extends EventTarget {
matches(selector) { assert.equal(selector, ':hover, :focus-within'); return this.held; }
}
function install() {
function install(dialogs = []) {
const toast = new FakeToast();
const previous = global.document;
global.document = {
querySelectorAll: selector => { assert.equal(selector, 'dialog'); return dialogs; },
getElementById: id => id === 'workspace-toast' ? toast : null,
createTextNode: text => ({ textContent: text }),
createElement: tag => ({ tag, href: '', textContent: '' }),
@@ -24,6 +25,18 @@ function install() {
return { toast, restore: () => { global.document = previous; } };
}
// First: the module remembers a toast it has shown.
test('a page without the toast shows nothing and does not fail', async () => {
const { showWorkspaceToast } = await source;
const previous = global.document;
global.document = { getElementById: () => null };
try {
assert.doesNotThrow(() => showWorkspaceToast('Nothing.'));
} finally {
global.document = previous;
}
});
test('a plain toast clears after 6.5 s and a linked one after 20 s', async t => {
const { showWorkspaceToast, TOAST_MS, LINK_TOAST_MS } = await source;
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -84,13 +97,50 @@ test('a toast under the pointer or focus waits until they leave', async t => {
}
});
test('a page without the toast shows nothing and does not fail', async () => {
class FakeHost extends EventTarget {
constructor(modal = false, localName = 'dialog') { super(); this.modal = modal; this.localName = localName; this.nodes = []; }
matches(selector) { assert.equal(selector, ':modal'); return this.modal; }
append(node) { node.parentNode?.nodes.splice(node.parentNode.nodes.indexOf(node), 1); node.parentNode = this; this.nodes.push(node); }
}
test('a toast shows inside the topmost open modal and goes home when it closes (#533)', async () => {
const { showWorkspaceToast } = await source;
const previous = global.document;
global.document = { getElementById: () => null };
const closed = new FakeHost(false), lower = new FakeHost(true), top = new FakeHost(true);
const dialogs = [closed, lower, top];
const { toast, restore } = install(dialogs);
const home = new FakeHost(false, 'body');home.append(toast);
try {
assert.doesNotThrow(() => showWorkspaceToast('Nothing.'));
showWorkspaceToast('Opened the folder on this computer.');
assert.equal(toast.parentNode, top);
// The upper modal closes: the toast stays visible in the one under it.
top.modal = false;top.dispatchEvent(new Event('close'));
assert.equal(toast.parentNode, lower);
lower.modal = false;lower.dispatchEvent(new Event('close'));
assert.equal(toast.parentNode, home);
showWorkspaceToast('Next.');
assert.equal(toast.parentNode, home);
} finally {
global.document = previous;
restore();
}
});
test('a toast removed along with its modal still shows the next message (#533)', async () => {
const { showWorkspaceToast } = await source;
const modal = new FakeHost(true);
const { toast, restore } = install([modal]);
const home = new FakeHost(false, 'body');home.append(toast);
try {
showWorkspaceToast('Inside.');
assert.equal(toast.parentNode, modal);
modal.modal = false;
const byId = document.getElementById;
document.getElementById = () => null; // the dialog left the page with the toast in it
showWorkspaceToast('After.');
assert.equal(toast.parentNode, home);
assert.equal(toast.textContent, 'After.');
document.getElementById = byId;
} finally {
restore();
}
});
+52
View File
@@ -26,6 +26,7 @@ from tests.test_recorded_saver_anchor import LLM_TEXT, text_generate_run
from utils.execution_contexts import ContextBundle
from utils.execution_history_events import make_finish, make_start
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
from py.api.images import media_thumbnail_rel
from py.config import GalleryConfig
@@ -212,6 +213,57 @@ class HistoryOutputAPITests(ImageAPITestCase):
self.assertNotIn("root=", candidate["media"][field])
self.assertNotIn(str(self.comfy_dir), candidate["media"][field])
async def test_an_output_in_a_trash_folder_is_read_by_no_kind(self):
# #543: inspection, media, thumbnail and the listing share one check.
path = make_png(self.output_dir / "image.png")
start = self.capture([self.ref()])
media = await self.media(start)
trashed = self.output_dir / ".Trash-1000" / "files"
trashed.mkdir(parents=True)
os.rename(path, trashed / "image.png")
try:
os.symlink(trashed / "image.png", path)
except OSError:
if os.environ.get("GITHUB_ACTIONS"):
raise
self.skipTest("this machine cannot make symlinks")
for field in ("inspection_url", "url", "thumbnail_url"):
with self.subTest(field=field):
response = await self.current(media[field])
self.assertIn(response.status, (404, 409, 410))
await response.read()
response, page = await self.outputs(start)
self.assertEqual(response.status, 200, page)
candidate = page["items"][0]["current_candidate"]
self.assertEqual(candidate["status"], "unavailable")
self.assertEqual(candidate["reason"], "missing")
async def test_a_preview_linked_into_a_trash_folder_does_not_open(self):
# #543: the cover picks its preview after the candidate check.
make_png(self.output_dir / "image.png")
trashed = self.output_dir / ".Trash-1000" / "files"
trashed.mkdir(parents=True)
make_png(trashed / "secret.png", color=(0, 0, 255))
preview = self.output_dir / media_thumbnail_rel(Path("image.png"))
preview.parent.mkdir(parents=True, exist_ok=True)
try:
os.symlink(trashed / "secret.png", preview)
except OSError:
if os.environ.get("GITHUB_ACTIONS"):
raise
self.skipTest("this machine cannot make symlinks")
media = await self.media(self.capture([self.ref()]))
response = await self.current(media["thumbnail_url"])
body = await response.read()
# Never the trashed bytes: the link is ignored or refused at the open.
self.assertNotEqual(body, (trashed / "secret.png").read_bytes())
mod = importlib.import_module("py.api.history_current")
scope = mod.snapshot_current_output_scope(self.api)
with self.assertRaises(mod.CurrentLocationUnavailable):
mod._open_current_target(
mod._canonical(preview), self.output_dir, scope, True
)
async def test_complete_zero_incomplete_unavailable_and_not_captured_are_distinct(
self,
):
+26
View File
@@ -1,6 +1,7 @@
"""Pair routes preserve shared text and expose portable combinations."""
from tests.test_api_prompts import PromptAPITestCase
from utils.hashing import generate_prompt_hash
class TestPairAPI(PromptAPITestCase):
@@ -9,6 +10,31 @@ class TestPairAPI(PromptAPITestCase):
payload.update(overrides)
return await self.client.post("/prompt_manager/pairs", json=payload)
async def _prompt_saved_by_route(self, text, **fields):
response = await self._create(positive_text=text, **fields)
self.assertEqual(response.status, 200)
return self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
async def test_a_new_prompt_gets_the_saved_category_and_tags(self):
prompt = await self._prompt_saved_by_route(
"route cat", category="Animals", tags=["studio"]
)
self.assertEqual(prompt["category"], "Animals")
self.assertEqual(prompt["tags"], ["studio"])
async def test_saving_never_changes_an_existing_prompt(self):
self.api.db.save_prompt(
"route dog",
category="Mine",
tags=["kept"],
prompt_hash=generate_prompt_hash("route dog"),
)
prompt = await self._prompt_saved_by_route(
"route dog", category="Animals", tags=["studio"]
)
self.assertEqual(prompt["category"], "Mine")
self.assertEqual(prompt["tags"], ["kept"])
async def test_create_reuse_read_and_search(self):
response = await self._create(tags=["studio"], title="Portrait")
self.assertEqual(response.status, 200)
+394
View File
@@ -0,0 +1,394 @@
"""Needs review: link an image to a library prompt by hand (#536).
The link is the reviewer's choice, never file evidence: it needs the same
capture of the same bytes as Apply, keeps the one-prompt-per-file rules, and
records provenance that cannot pose as what the file says.
"""
import json
import os
import unittest
try:
from tests.test_api_review import ITEMS_URL, ReviewRoutesTestCase
from tests.test_review import digest, unlinkable_graph, write_png
except ImportError: # discovered with tests/ as the top-level directory
from test_api_review import ITEMS_URL, ReviewRoutesTestCase
from test_review import digest, unlinkable_graph, write_png
from utils.hashing import generate_prompt_hash
class LinkTestCase(ReviewRoutesTestCase):
def _pair(self, positive="harbor", negative="blurry"):
pair_id = self.api.db.save_prompt_pair(positive, negative)
pair = self.api.db.get_prompt_pair(pair_id)
return pair["positive_prompt_id"], pair_id
def _link_body(self, detail, observed, positive_id, pair_id):
return {
"expected_epoch": observed["library_epoch"],
"expected_revision": detail["revision"],
"capture_token": observed["capture"]["token"],
"sha256": observed["capture"]["sha256"],
"dependency_fingerprint": detail["dependency_fingerprint"],
"positive_id": positive_id,
"pair_id": pair_id,
}
async def _link(self, item_id, body):
return await self._json("POST", f"{ITEMS_URL}/{item_id}/link", json=body)
async def _queued(self):
self._record(self._ambiguous("two.png"))
return await self._open()
async def _refused(self, item, body, status, code):
before = self._snapshot()
got, data = await self._link(item["id"], body)
self.assertEqual(got, status, data)
self.assertEqual(data.get("code"), code, data)
self.assertEqual(self._snapshot(), before)
return data
class TestLink(LinkTestCase):
async def test_links_the_chosen_recipe_and_records_a_manual_decision(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
revision = self._review_revision()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
self.assertEqual(data["total"], 0)
self.assertEqual(data["item"]["state"], "selected")
self.assertEqual(data["pair"]["id"], pair_id)
conn = self.api.db.model.get_connection()
image = conn.execute("SELECT id, prompt_id FROM generated_images").fetchone()
self.assertEqual(image[1], positive_id)
(link,) = self.api.db.get_image_prompt_pairs(image[0])
(decision,) = self._decisions()
self.assertEqual(link["pair_id"], pair_id)
self.assertEqual(
link["provenance"],
{
"kind": "manual-link",
"decision_id": decision["id"],
"reviewed_digest": digest(path),
"resolver_version": detail_version(self, item),
"positive_id": positive_id,
"pair_id": pair_id,
},
)
self.assertEqual(decision["outcome"], "selected")
self.assertRegex(decision["candidate_hash"], r"^[0-9a-f]{64}$")
self.assertNotIn(
decision["candidate_hash"], [c["hash"] for c in detail["candidates"]]
)
self.assertEqual(decision["pair_id"], pair_id)
evidence = json.loads(decision["evidence"])
self.assertEqual(evidence["kind"], "manual-link")
self.assertIsNone(evidence["candidate"])
self.assertEqual(
evidence["manual"], {"positive_id": positive_id, "pair_id": pair_id}
)
self.assertEqual(self._review_revision(), revision + 1)
self._no_paths()
async def test_a_manual_link_does_not_pose_as_file_evidence(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
saved = self.api.db.get_image_prompt_info(str(path))
self.assertEqual(saved["text"], "harbor")
self.assertIsNone(saved.get("pair_context"))
async def test_no_negative_links_a_pair_without_one(self):
positive_id, _pair_id = self._pair()
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, None)
)
self.assertEqual(status, 200, data)
pair = self.api.db.get_prompt_pair(data["pair"]["id"])
self.assertEqual(pair["positive_prompt_id"], positive_id)
self.assertIsNone(pair["negative_prompt_id"])
decision = self._decisions()[0]
self.assertEqual(
json.loads(decision["evidence"])["manual"]["pair_id"], data["pair"]["id"]
)
async def test_a_prompt_without_recipes_can_be_linked(self):
prompt_id = self.api.db.save_prompt(
"lone", prompt_hash=generate_prompt_hash("lone")
)
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, prompt_id, None)
)
self.assertEqual(status, 200, data)
async def test_an_existing_row_of_the_same_prompt_is_reused(self):
positive_id, pair_id = self._pair()
path = self._ambiguous("two.png")
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
self._record(path)
item, detail, observed = await self._open()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
self.assertEqual(data["pair"]["image_id"], image_id)
async def test_a_candidates_own_item_can_still_be_linked_after_a_refusal(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(
item, {**body, "pair_id": pair_id + 99}, 409, "prompt-missing"
)
status, data = await self._link(item["id"], body)
self.assertEqual(status, 200, data)
async def test_a_later_scan_leaves_the_link_and_does_not_queue_it(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
before = self._snapshot()
self.assertIn(self._record_raw(path), ("decided", "duplicate"))
self.assertEqual(self._snapshot(), before)
self.assertEqual((await self._list())["total"], 0)
class TestLinkRefusals(LinkTestCase):
async def test_another_positive_on_the_file_is_an_association_conflict(self):
positive_id, pair_id = self._pair()
path = self._ambiguous("two.png")
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
self.api.db.link_image_to_prompt(other, str(path))
self._record(path)
item, detail, observed = await self._open()
body = self._link_body(detail, observed, positive_id, pair_id)
data = await self._refused(item, body, 409, "association-conflict")
self.assertIn("linked", data["error"])
async def test_a_prior_link_with_another_negative_is_an_association_conflict(self):
positive_id, pair_id = self._pair()
_same, other_pair = self._pair(negative="grainy")
path = self._ambiguous("two.png")
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
self.api.db.link_image_to_pair(other_pair, image_id, {"kind": "test"})
self._record(path)
item, detail, observed = await self._open()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "association-conflict")
async def test_changed_links_since_opening_are_refused(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
self.api.db.link_image_to_prompt(other, str(path))
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "association-conflict")
async def test_unusable_choices_are_refused_with_zero_writes(self):
positive_id, pair_id = self._pair()
foreign_positive, foreign_pair = self._pair("elsewhere", "bad")
negative_only = self.api.db.get_prompt_pair(pair_id)["negative_prompt_id"]
archived_positive, archived_pair = self._pair("old", "stale")
self.api.db.archive_prompt_pair(archived_pair)
item, detail, observed = await self._queued()
for chosen_positive, chosen_pair in (
(positive_id, foreign_pair),
(positive_id, pair_id + 99),
(positive_id + 99, None),
(negative_only, None),
(archived_positive, archived_pair),
):
body = self._link_body(detail, observed, chosen_positive, chosen_pair)
await self._refused(item, body, 409, "prompt-missing")
self.assertTrue(foreign_positive)
async def test_an_archived_prompt_is_refused(self):
positive_id, pair_id = self._pair()
conn = self.api.db.model.get_connection()
with conn:
conn.execute(
"INSERT INTO prompt_archive(prompt_id,archived_at) "
"VALUES(?,'2026-01-01T00:00:00Z')",
(positive_id,),
)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "prompt-missing")
async def test_a_stale_stored_hash_never_links_another_row(self):
positive_id, _pair_id = self._pair()
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
conn = self.api.db.model.get_connection()
with conn:
conn.execute(
"UPDATE prompts SET hash=? WHERE id=?",
(generate_prompt_hash("elsewhere"), other),
)
conn.execute(
"UPDATE prompts SET hash=? WHERE id=?",
(generate_prompt_hash("other"), positive_id),
)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, other, None)
await self._refused(item, body, 409, "prompt-missing")
async def test_an_archived_bare_pair_is_refused(self):
positive_id, pair_id = self._pair()
bare = self.api.db.save_prompt_pair("harbor", "")
self.api.db.archive_prompt_pair(bare)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, None)
await self._refused(item, body, 409, "prompt-missing")
self.assertTrue(pair_id)
async def test_body_validation(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
before = self._snapshot()
for bad in (
{**good, "extra": 1},
{k: v for k, v in good.items() if k != "pair_id"},
{**good, "positive_id": "1"},
{**good, "positive_id": True},
{**good, "positive_id": 0},
{**good, "pair_id": -1},
{**good, "capture_token": None},
{**good, "expected_revision": -1},
{**good, "sha256": "x"},
):
status, data = await self._link(item["id"], bad)
self.assertEqual(status, 400, (bad, data))
self.assertEqual(self._snapshot(), before)
async def test_stale_revision_and_decided_item_are_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(
item, {**good, "expected_revision": 5}, 409, "review-conflict"
)
status, data = await self._link(item["id"], good)
self.assertEqual(status, 200, data)
again = await self._observe(item["target"])
await self._refused(
item,
{**good, "capture_token": again["capture"]["token"]},
409,
"review-conflict",
)
async def test_missing_item_expired_capture_and_changed_file_are_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
status, data = await self._link("00000000-0000-4000-8000-000000000000", good)
self.assertEqual((status, data["code"]), (404, "review-missing"))
await self._refused(
item, {**good, "capture_token": "a" * 43}, 409, "capture-expired"
)
write_png(
self.output_dir / "two.png", prompt=unlinkable_graph(), color=(1, 2, 3)
)
with_fresh = await self._observe(item["target"])
await self._refused(
item,
{
**good,
"capture_token": with_fresh["capture"]["token"],
"sha256": with_fresh["capture"]["sha256"],
},
409,
"file-changed",
)
async def test_restored_storage_is_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
body = {
**self._link_body(detail, observed, positive_id, pair_id),
"expected_epoch": "00000000-0000-4000-8000-000000000000",
}
await self._refused(item, body, 409, "storage-changed")
def detail_version(case, item):
conn = case.api.db.model.get_connection()
return conn.execute(
"SELECT resolver_version FROM media_review_items WHERE id=?", (item["id"],)
).fetchone()[0]
class TestLinkNeverActsOnATrashedFile(LinkTestCase):
"""#543's gate: a link needs a capture, and neither way to one reaches a trash."""
TRASH = ".Trash-1000"
async def _captured_in_sub(self):
path = self._ambiguous("sub/two.png")
self._record(path)
item, detail, observed = await self._open()
positive_id, pair_id = self._pair()
return path, item, self._link_body(detail, observed, positive_id, pair_id)
async def _assert_refused(self, item, body):
before = self._snapshot()
status, data = await self._link(item["id"], body)
self.assertGreaterEqual(status, 400, data)
self.assertFalse(data.get("success"), data)
self.assertEqual(self._snapshot(), before)
async def test_a_file_moved_to_a_trash_folder_after_its_capture_is_refused(self):
path, item, body = await self._captured_in_sub()
trash = self.output_dir / self.TRASH / "files"
trash.mkdir(parents=True)
os.rename(path, trash / path.name)
await self._assert_refused(item, body)
async def test_a_folder_swapped_for_a_link_into_a_trash_folder_is_refused(self):
path, item, body = await self._captured_in_sub()
trash = self.output_dir / self.TRASH / "files"
trash.parent.mkdir(parents=True)
os.rename(path.parent, trash)
try:
os.symlink(trash, path.parent, target_is_directory=True)
except OSError as exc:
raise unittest.SkipTest(f"no symlinks here: {exc}")
self.assertTrue(path.exists())
await self._assert_refused(item, body)
async def test_an_item_in_a_trash_folder_cannot_get_a_capture(self):
path = self._ambiguous(f"{self.TRASH}/files/two.png")
self._record(path)
item = (await self._list())["items"][0]
status, data = await self._json(
"POST",
"/prompt_manager/image-annotations/observe",
json={"target": item["target"]},
)
self.assertEqual(status, 404, data)
self.assertFalse(data.get("capture"), data)
+248 -105
View File
@@ -1,4 +1,4 @@
"""Show in folder (TESTING.md D38): the local-only reveal route and its openers.
"""Show in folder (TESTING.md D38): the same-origin reveal route and its openers.
No test launches a file manager: ``subprocess.Popen``, ``sys.platform`` and
``shutil.which`` are mocked, and route tests swap the API's opener.
@@ -29,28 +29,11 @@ except ImportError:
import utils.file_reveal as file_reveal
from utils.file_reveal import (
OpenerUnavailable,
is_local_request,
is_same_origin_request,
reveal_in_file_manager,
)
REVEAL = "/prompt_manager/images/reveal"
PROXY_HEADERS = (
"Forwarded",
"Via",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Forwarded-Port",
"X-Forwarded-Server",
"X-Real-IP",
"X-Original-Forwarded-For",
"X-Original-Host",
"CF-Connecting-IP",
"True-Client-IP",
"X-Client-IP",
"X-Cluster-Client-IP",
"Fastly-Client-IP",
)
ORIGIN = "http://127.0.0.1:8188"
@@ -60,84 +43,126 @@ def headers(**values):
return CIMultiDict({key: value for key, value in base.items() if value is not None})
class TestIsLocalRequest(unittest.TestCase):
def test_same_origin_loopback_clients_by_loopback_name_are_local(self):
for remote, host, origin in (
("127.0.0.1", "127.0.0.1:8188", ORIGIN),
("127.8.9.10", "localhost:8188", "http://localhost:8188"),
("::1", "[::1]:8188", "http://[::1]:8188"),
("::ffff:127.0.0.1", "comfy.localhost", "http://comfy.localhost"),
("127.0.0.1", "LOCALHOST:8188", "http://localhost:8188"),
("127.0.0.1", "localhost", "http://localhost:80"),
SITE = "comfy.sansfam.org"
SITE_ORIGIN = f"https://{SITE}"
# What Caddy's plain reverse_proxy sends: https at the edge, http to ComfyUI.
CADDY = {
"Host": SITE,
"Origin": SITE_ORIGIN,
"X_Forwarded_For": "192.168.1.30",
"X_Forwarded_Host": SITE,
"X_Forwarded_Proto": "https",
"Sec_Fetch_Site": "same-origin",
}
class TestIsSameOriginRequest(unittest.TestCase):
def test_direct_browsers_on_any_address_are_same_origin(self):
for host, origin in (
("127.0.0.1:8188", ORIGIN),
("localhost:8188", "http://localhost:8188"),
("[::1]:8188", "http://[::1]:8188"),
("LOCALHOST:8188", "http://localhost:8188"),
("localhost", "http://localhost:80"),
("10.0.140.30:8188", "http://10.0.140.30:8188"),
("vito-cachyos:8188", "http://vito-cachyos:8188"),
):
with self.subTest(remote=remote, host=host):
with self.subTest(host=host):
self.assertTrue(
is_local_request(remote, headers(Host=host, Origin=origin))
is_same_origin_request(headers(Host=host, Origin=origin))
)
self.assertTrue(
is_local_request(
"127.0.0.1",
headers(Host="localhost", Origin="https://localhost:443"),
"https",
is_same_origin_request(
headers(Host="localhost", Origin="https://localhost:443"), "https"
)
)
def test_non_loopback_or_unknown_remote_is_not_local(self):
for remote in ("192.168.1.5", "10.0.0.1", "::2", "fe80::1%eth0", "", None):
with self.subTest(remote=remote):
self.assertFalse(is_local_request(remote, headers()))
def test_the_proxied_request_shape_is_same_origin(self):
self.assertTrue(is_same_origin_request(headers(**CADDY), "http"))
shapes = {
"no forwarded headers, https at the edge": {
"Host": SITE,
"Origin": SITE_ORIGIN,
},
"proxy rewrites Host, sends X-Forwarded-Host": {
**CADDY,
"Host": "10.0.140.30:8188",
},
"a chain of proxies lists the client's first": {
**CADDY,
"X_Forwarded_Host": f"{SITE}, inner.lan",
"X_Forwarded_Proto": "HTTPS, http",
},
"explicit default port on Host": {**CADDY, "Host": f"{SITE}:443"},
"a non-default edge port": {
**CADDY,
"Host": f"{SITE}:8443",
"X_Forwarded_Host": f"{SITE}:8443",
"Origin": f"{SITE_ORIGIN}:8443",
},
}
for name, shape in shapes.items():
with self.subTest(name):
self.assertTrue(is_same_origin_request(headers(**shape), "http"))
def test_any_forwarding_header_makes_loopback_remote(self):
for name in (*PROXY_HEADERS, "X-Forwarded-Anything", "X-Original-URI"):
for spelling in (name, name.lower()):
with self.subTest(header=spelling):
request_headers = headers()
request_headers[spelling] = "127.0.0.1"
self.assertFalse(is_local_request("127.0.0.1", request_headers))
def test_a_proxy_that_rewrites_host_and_says_nothing_is_refused(self):
self.assertFalse(
is_same_origin_request(
headers(Host="10.0.140.30:8188", Origin=SITE_ORIGIN), "http"
)
)
def test_origin_is_required_and_must_be_the_request_origin(self):
# A header-less proxy or curl leaves Origin out; a page on another
# loopback port or scheme sends its own (security review M1, L2).
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=None)))
def test_cross_site_and_spoofed_requests_are_refused(self):
evil = "https://evil.example"
cases = {
"cross-site Origin through the proxy": {**CADDY, "Origin": evil},
"cross-site Origin, direct": {"Origin": evil},
"another port": {"Origin": "http://127.0.0.1:3000"},
"another scheme than the proxy says": {
**CADDY,
"Origin": f"http://{SITE}",
},
"forwarded host naming the attacker": {
**CADDY,
"X_Forwarded_Host": "evil.example",
},
"Host naming the attacker": {"Host": "evil.example", "Origin": ORIGIN},
"lookalike suffix": {**CADDY, "Origin": f"https://{SITE}.evil.example"},
"cross-site fetch metadata with a forged Origin": {
**CADDY,
"Sec_Fetch_Site": "cross-site",
},
"same-site fetch metadata": {**CADDY, "Sec_Fetch_Site": "same-site"},
}
for name, shape in cases.items():
with self.subTest(name):
self.assertFalse(is_same_origin_request(headers(**shape), "http"))
def test_a_missing_or_malformed_origin_is_refused(self):
for origin in (
None,
"",
"null",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://127.0.0.1",
"https://127.0.0.1:8188",
"http://localhost:8188",
"http://foo.localhost:8188",
"http://evil.example:8188",
"http://[bad",
"http://127.0.0.1:8188/path",
"http://user@127.0.0.1:8188",
"file://127.0.0.1:8188",
):
with self.subTest(origin=origin):
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=origin)))
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=ORIGIN), "https"))
self.assertFalse(is_same_origin_request(headers(Origin=origin)))
def test_sec_fetch_site_must_be_same_origin_when_sent(self):
self.assertTrue(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site="same-origin"))
)
for site in ("same-site", "cross-site", "none", ""):
with self.subTest(site=site):
self.assertFalse(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site=site))
)
def test_a_downgraded_direct_origin_is_refused(self):
# http at the socket allows an https page (TLS ended before us), but a
# TLS socket never takes an http page.
self.assertFalse(is_same_origin_request(headers(), "https"))
def test_foreign_host_is_not_local(self):
# A DNS-rebinding page reaches 127.0.0.1 under its own name.
for host in ("evil.example:8188", "", "[bad", "127.0.0.1:bad"):
def test_a_missing_or_malformed_host_is_refused(self):
for host in ("", "[bad", "127.0.0.1:bad"):
with self.subTest(host=host):
self.assertFalse(
is_local_request(
"127.0.0.1", headers(Host=host, Origin=f"http://{host}")
)
is_same_origin_request(headers(Host=host, Origin=f"http://{host}"))
)
self.assertFalse(is_local_request("127.0.0.1", CIMultiDict({"Origin": ORIGIN})))
self.assertFalse(is_same_origin_request(CIMultiDict({"Origin": ORIGIN})))
class TestRevealGate(unittest.TestCase):
@@ -171,7 +196,10 @@ class TestRevealInFileManager(unittest.TestCase):
self.path.parent.mkdir(parents=True)
self.path.write_bytes(b"png")
self.popen = patch.object(subprocess, "Popen").start()
self.popen.return_value.wait.return_value = 0
self.reaped = patch.object(file_reveal, "_reap").start()
# A desktop session unless a test says otherwise; CI has no display.
patch.dict(os.environ, {"DISPLAY": ":0"}).start()
self.addCleanup(patch.stopall)
def reveal(self, path=None):
@@ -262,8 +290,72 @@ class TestRevealInFileManager(unittest.TestCase):
self.reveal()
self.assertEqual(self.argv(), ["/usr/bin/open", "-R", str(self.path)])
self.assert_detached()
self.popen.return_value.wait.assert_not_called()
self.reaped.assert_called_once_with(self.popen.return_value)
self.popen.return_value.wait.assert_called_once_with(
timeout=file_reveal.OPENER_WAIT
)
self.reaped.assert_not_called()
def test_a_failing_opener_is_reported_on_macos_and_linux(self):
cases = {
"darwin": patch.object(
file_reveal, "_macos_open", return_value="/usr/bin/open"
),
"linux": self.which({"xdg-open": "/usr/bin/xdg-open"}),
}
for platform, opener in cases.items():
with self.subTest(platform):
self.popen.return_value.wait.return_value = 4
with (
patch.object(file_reveal.sys, "platform", platform),
opener,
self.assertRaises(file_reveal.OpenerFailed),
):
self.reveal()
self.reaped.assert_not_called()
def test_an_opener_still_running_counts_as_started_and_is_reaped(self):
cases = {
"darwin": patch.object(
file_reveal, "_macos_open", return_value="/usr/bin/open"
),
"linux": self.which({"xdg-open": "/usr/bin/xdg-open"}),
}
for platform, opener in cases.items():
with self.subTest(platform):
self.reaped.reset_mock()
process = self.popen.return_value
process.wait.side_effect = subprocess.TimeoutExpired("opener", 1)
with patch.object(file_reveal.sys, "platform", platform), opener:
self.reveal()
self.reaped.assert_called_once_with(process)
def test_linux_without_a_display_or_a_service_is_unavailable(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
patch.dict(os.environ, {"DISPLAY": "", "WAYLAND_DISPLAY": ""}),
self.which({"xdg-open": "/usr/bin/xdg-open"}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_linux_on_wayland_alone_still_runs_xdg_open(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
patch.dict(os.environ, {"DISPLAY": "", "WAYLAND_DISPLAY": "wayland-0"}),
self.which({"xdg-open": "/usr/bin/xdg-open"}),
):
self.reveal()
self.assertEqual(self.argv(), ["/usr/bin/xdg-open", str(self.path.parent)])
def test_the_service_answers_without_needing_a_display(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
patch.dict(os.environ, {"DISPLAY": "", "WAYLAND_DISPLAY": ""}),
self.which({"dbus-send": "/usr/bin/dbus-send"}),
):
self.reveal()
self.assertEqual(self.popen.call_count, 1)
def test_macos_open_is_the_fixed_system_path(self):
self.assertEqual(file_reveal.MACOS_OPEN, "/usr/bin/open")
@@ -323,7 +415,14 @@ class TestRevealInFileManager(unittest.TestCase):
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_HOME": "/home/u/.local/share",
"XDG_DATA_DIRS": "/usr/share",
# What xdg-open reads to choose an opener (kde-open6 on Plasma 6).
"KDE_FULL_SESSION": "true",
"KDE_SESSION_VERSION": "6",
"GNOME_DESKTOP_SESSION_ID": "this-is-deprecated",
"MATE_DESKTOP_SESSION_ID": "this-is-mate",
"LXQT_SESSION_CONFIG": "session",
}
with patch.dict(os.environ, inherited, clear=True):
env = file_reveal._child_env()
@@ -342,7 +441,13 @@ class TestRevealInFileManager(unittest.TestCase):
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_HOME": "/home/u/.local/share",
"XDG_DATA_DIRS": "/usr/share",
"KDE_FULL_SESSION": "true",
"KDE_SESSION_VERSION": "6",
"GNOME_DESKTOP_SESSION_ID": "this-is-deprecated",
"MATE_DESKTOP_SESSION_ID": "this-is-mate",
"LXQT_SESSION_CONFIG": "session",
},
)
self.assertNotIn("/venv", file_reveal.SYSTEM_PATH)
@@ -387,15 +492,19 @@ class TestRevealInFileManager(unittest.TestCase):
for name, (available, outcome) in cases.items():
with self.subTest(name):
self.popen.reset_mock(side_effect=True, return_value=True)
self.reaped.reset_mock()
dbus = MagicMock()
opener = MagicMock()
opener.wait.return_value = 0
if isinstance(outcome, OSError):
self.popen.side_effect = [outcome, MagicMock()]
self.popen.side_effect = [outcome, opener]
elif isinstance(outcome, subprocess.TimeoutExpired):
dbus.wait.side_effect = [outcome, None]
self.popen.side_effect = [dbus, MagicMock()]
self.popen.side_effect = [dbus, opener]
elif outcome is None:
self.popen.side_effect = [opener]
else:
self.popen.return_value.wait.return_value = outcome
dbus.wait.return_value = outcome
self.popen.side_effect = [dbus, opener]
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which(available),
@@ -406,7 +515,7 @@ class TestRevealInFileManager(unittest.TestCase):
["/usr/bin/xdg-open", str(self.path.parent)],
)
self.assert_detached(self.popen.call_count - 1)
self.reaped.assert_called_once()
opener.wait.assert_called_once_with(timeout=file_reveal.OPENER_WAIT)
if name == "hung":
dbus.kill.assert_called_once_with()
@@ -451,6 +560,33 @@ class TestRevealInFileManager(unittest.TestCase):
shell.assert_not_called()
self.popen.assert_not_called()
def test_a_path_outside_the_root_reaches_no_opener_on_any_branch(self):
# What limits a same-origin request's reach (#533): one check ahead of
# every branch, needing no symlink, so it also runs on plain Windows.
outside = self.root.parent / "elsewhere.png"
outside.write_bytes(b"png")
both = {"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/usr/bin/xdg-open"}
shell = FakeShell()
startfile = MagicMock()
for platform in ("win32", "darwin", "linux"):
with (
self.subTest(platform=platform),
patch.object(file_reveal.sys, "platform", platform),
patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
) as libraries,
patch.object(file_reveal.os, "startfile", startfile, create=True),
patch.object(file_reveal, "_macos_open", return_value="/usr/bin/open"),
self.which(both),
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal(outside)
libraries.assert_not_called()
startfile.assert_not_called()
self.popen.assert_not_called()
def test_a_path_on_another_drive_is_not_contained(self):
# Windows: commonpath raises ValueError across drives.
with (
@@ -686,7 +822,7 @@ class TestRevealRoute(ImageAPITestCase):
def body(self, path="sub/a.png", **changes):
return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes}
async def test_loopback_request_opens_the_resolved_file(self):
async def test_same_origin_request_opens_the_resolved_file(self):
target = make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body())
self.assertEqual((status, data), (200, {"success": True}))
@@ -736,40 +872,47 @@ class TestRevealRoute(ImageAPITestCase):
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
async def test_non_loopback_remote_is_not_local(self):
async def test_a_proxied_click_opens_the_folder_on_this_computer(self):
make_png(self.output_dir / "sub" / "a.png")
with patch.object(
web.BaseRequest, "remote", new_callable=PropertyMock
) as remote:
remote.return_value = "192.168.1.20"
status, data = await self.post(self.body())
self.assertEqual(status, 403)
self.assertEqual(data["code"], "not-local")
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
remote.return_value = "172.18.0.2"
status, _ = await self.post(
self.body(),
Host=SITE,
Origin=SITE_ORIGIN,
**{
"X-Forwarded-For": "192.168.1.30",
"X-Forwarded-Host": SITE,
"X-Forwarded-Proto": "https",
"Sec-Fetch-Site": "same-origin",
},
)
self.assertEqual(status, 200)
self.assertEqual(len(self.opened), 1)
async def test_loopback_with_a_proxy_header_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
for name in PROXY_HEADERS:
with self.subTest(header=name):
status, data = await self.post(self.body(), **{name: "127.0.0.1"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), Host="evil.example:8188")
self.assertEqual((status, data["code"]), (403, "not-local"))
self.assertEqual(self.opened, [])
async def test_missing_or_foreign_origin_is_not_local(self):
async def test_a_cross_site_request_opens_nothing(self):
make_png(self.output_dir / "sub" / "a.png")
port = self.client.make_url("/").port
for origin in (None, f"http://127.0.0.1:{port + 1}", "null"):
with self.subTest(origin=origin):
status, data = await self.post(self.body(), Origin=origin)
self.assertEqual((status, data["code"]), (403, "not-local"))
self.assertEqual((status, data["code"]), (403, "not-same-origin"))
self.assertIs(data["success"], False)
status, data = await self.post(self.body(), **{"Sec-Fetch-Site": "same-site"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, _ = await self.post(self.body(), **{"Sec-Fetch-Site": "same-origin"})
self.assertEqual(status, 200)
self.assertEqual(len(self.opened), 1)
self.assertEqual((status, data["code"]), (403, "not-same-origin"))
self.assertEqual(self.opened, [])
async def test_the_path_check_still_applies_to_a_proxied_click(self):
make_png(self.output_dir / "sub" / "a.png")
shape = {"Host": SITE, "Origin": SITE_ORIGIN, "X-Forwarded-Proto": "https"}
for path in ("../secret.png", "/etc/passwd.png", "C:/x.png"):
with self.subTest(path=path):
body = {**self.body(), "path": path}
status, _ = await self.post(body, **shape)
self.assertEqual(status, 400)
self.assertEqual(self.opened, [])
async def test_only_json_bodies_are_accepted(self):
# text/plain would be a CORS "simple" request with no preflight (L2).
+93 -2
View File
@@ -13,6 +13,7 @@ except ImportError: # discovered with tests/ as the top-level directory
from database.operations import PromptDatabase
from prompt_manager_pair import PromptManagerPair
from utils.hashing import generate_prompt_hash
from utils.usage_tracking import PendingFirstUse
@@ -109,14 +110,104 @@ class TestPromptPairNode(unittest.TestCase):
),
("cat", "blur", "Animals", ["studio"]),
)
self.assertIsNone(
self.db.get_prompt_by_id(pair["positive_prompt_id"])["category"]
self.assertEqual(
self.db.get_prompt_by_id(pair["positive_prompt_id"])["category"],
"Animals",
)
tracked = self.node.prompt_tracker.set_current_prompt.call_args.kwargs
self.assertEqual(tracked["additional_data"]["pair_id"], pair["id"])
self.assertEqual(tracked["additional_data"]["negative_text"], " blur ")
self.assertEqual(tracked["additional_data"]["positive_storage_text"], "cat")
def save(self, text="cat", negative="blur", **widgets):
self.node.encode_prompt(
self.clip,
text,
negative_text=negative,
prompt=pair_graph(text, negative),
unique_id="1",
**widgets,
)
hash_of = generate_prompt_hash
return (
self.db.get_prompt_by_hash(hash_of(text)),
self.db.get_prompt_by_hash(hash_of(negative)),
)
def test_first_save_gives_the_positive_its_category_and_tags(self):
positive, negative = self.save(category=" Animals ", tags="studio, macro")
self.assertEqual(positive["category"], "Animals")
self.assertCountEqual(positive["tags"], ["studio", "macro"])
self.assertIsNone(negative["category"])
self.assertEqual(negative["tags"], [])
def test_a_later_save_never_changes_a_category_or_tags_already_set(self):
self.save(category="Animals", tags="studio")
positive, _ = self.save(negative="noise", category="Pets", tags="other")
self.assertEqual(positive["category"], "Animals")
self.assertEqual(positive["tags"], ["studio"])
def test_a_save_keeps_metadata_a_person_set_on_the_prompt(self):
text = "cat"
self.db.save_prompt(
text,
category="Mine",
tags=["kept"],
prompt_hash=generate_prompt_hash(text),
)
positive, _ = self.save(category="Animals", tags="studio")
self.assertEqual(positive["category"], "Mine")
self.assertEqual(positive["tags"], ["kept"])
def test_a_run_on_an_existing_empty_prompt_changes_nothing(self):
self.db.save_prompt("cat", prompt_hash=generate_prompt_hash("cat"))
before = self.db.get_prompt_by_hash(generate_prompt_hash("cat"))["updated_at"]
positive, _ = self.save(category="Animals", tags="studio")
self.assertIsNone(positive["category"])
self.assertEqual(positive["tags"], [])
self.assertEqual(positive["updated_at"], before)
def test_a_pair_saved_without_metadata_stays_empty_on_a_later_run(self):
self.save()
positive, _ = self.save(negative="noise", category="Animals", tags="studio")
self.assertIsNone(positive["category"])
self.assertEqual(positive["tags"], [])
def test_a_cleared_category_and_tags_stay_cleared(self):
positive, _ = self.save(category="Animals", tags="studio")
self.db.update_prompt_metadata(positive["id"], category="", tags=[])
positive, _ = self.save(negative="noise", category="Animals", tags="studio")
self.assertEqual(positive["category"], "")
self.assertEqual(positive["tags"], [])
def test_a_prompt_first_seen_as_a_negative_stays_empty(self):
self.save(text="dog", negative="cat")
positive, _ = self.save(text="cat", negative="blur", category="Animals")
self.assertIsNone(positive["category"])
def test_blank_widgets_leave_the_prompt_empty(self):
positive, _ = self.save(category=" ", tags=" ")
self.assertIsNone(positive["category"])
self.assertEqual(positive["tags"], [])
def test_a_filled_prompt_does_not_change_the_saved_pair(self):
self.save(category="Animals", tags="studio")
positive, _ = self.save(category="Pets", tags="other")
pairs = self.db.list_prompt_pairs()["pairs"]
self.assertEqual(
[(pair["category"], pair["tags"]) for pair in pairs],
[("Animals", ["studio"])],
)
self.assertEqual(positive["category"], "Animals")
def test_library_shows_filled_metadata_without_repeating_a_tag(self):
self.save(category="Animals", tags="studio")
item = self.db.list_library()["items"][0]
self.assertEqual(item["category"], "Animals")
self.assertEqual(item["tags"], ["studio"])
tag = self.db.list_vocabulary_tags(limit=5)["tags"][0]
self.assertEqual(tag["prompt_count"], 1)
def test_empty_negative_encodes_empty_text_and_stores_null_component(self):
result = self.node.encode_prompt(self.clip, "cat", negative_text="")
self.assertEqual(result[3], "")
+45
View File
@@ -285,6 +285,51 @@ class DryRunTests(RepairFixture):
self.assertIn("would lose every link", markdown)
class ManualLinkTests(RepairFixture):
"""A prompt the reviewer named in Needs review (#536) is not ours to undo."""
def mark_manual(self, key):
row = self.rows[key]
((prompt,),) = self.query(
"SELECT prompt_id FROM generated_images WHERE id=?", (row,)
)
self.query_write(
"INSERT OR IGNORE INTO prompt_pairs(positive_prompt_id,created_at,"
"updated_at) VALUES(?,'2026-10-30T00:00:00Z','2026-10-30T00:00:00Z')",
(prompt,),
)
((pair,),) = self.query(
"SELECT id FROM prompt_pairs WHERE positive_prompt_id=? "
"AND negative_prompt_id IS NULL",
(prompt,),
)
self.query_write(
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
"VALUES(?,?,?,'2026-10-30T00:00:00Z')",
(pair, row, '{"kind":"manual-link"}'),
)
def test_a_manual_link_is_skipped_in_the_plan(self):
self.mark_manual("A")
code, out = self.dry_run()
self.assertEqual(code, 0, out)
items = {item["row_id"]: item for item in self.plan()["rows"]}
item = items[self.rows["A"]]
self.assertEqual(item["action"], "skip")
self.assertEqual(item["reason"], "linked by hand in Needs review")
self.assertEqual(items[self.rows["B"]]["action"], "relink")
def test_a_manual_link_made_after_the_plan_refuses_the_apply(self):
code, out = self.dry_run()
self.assertEqual(code, 0, out)
self.mark_manual("A")
before = self.links()
code, out = self.apply()
self.assertNotEqual(code, 0, out)
self.assertIn("action changed", out)
self.assertEqual(self.links(), before)
class DryRunReviewTests(RepairFixture):
def test_plan_records_its_library_and_fingerprint(self):
self.dry_run()
+15
View File
@@ -104,6 +104,21 @@ class ReviewToolTests(ToolFixture):
sorted(os.listdir(self.tmp)), ["output", "prompts.db"], "no side files"
)
def test_a_manual_link_is_not_ours_to_undo(self):
db = PromptDatabase(self.db_path)
try:
pair = db.save_prompt_pair("a woman holding a coffee cup", "")
db.link_image_to_pair(pair, self.guessed[0], {"kind": "manual-link"})
finally:
db.close_all()
before = self.links()
code, out = self.run_tool("--ids", str(self.guessed[0]), "--json")
self.assertEqual(code, 0)
(verdict,) = json.loads(out.split("--apply, for")[0])
self.assertEqual(verdict["verdict"], "manual")
code, _out = self.run_tool("--ids", str(self.guessed[0]), "--apply")
self.assertEqual(self.links(), before)
def test_prompt_selection_and_text_report(self):
code, out = self.run_tool("--prompt", str(self.coffee))
self.assertEqual(code, 0)
+6 -6
View File
@@ -114,11 +114,12 @@ class FreedesktopTrashTests(unittest.TestCase):
self.assertEqual(os.listdir(elsewhere), [])
def test_trash_inside_an_output_root_is_refused(self):
# The drive's top folder is itself an output root: its .Trash-uid
# would be listed as images.
# A root above the drive's top folder would hold the drive's .Trash-uid.
# (A root that is exactly the top folder is allowed: #543,
# tests/test_trash_mount_root.py.)
path = self._file(self.root)
with self.assertRaises(trash.TrashRefused) as caught:
self.backend.move(path, [self.drive])
self.backend.move(path, [self.tmp])
self.assertEqual(caught.exception.code, trash.TRASH_IN_ROOT)
self.assertTrue(os.path.exists(path))
self.assertFalse(
@@ -147,9 +148,8 @@ class FreedesktopTrashTests(unittest.TestCase):
def test_available_reports_without_writing(self):
path = self._file(self.root)
self.assertIsNone(self.backend.available(path, [self.root]))
self.assertEqual(
self.backend.available(path, [self.drive]), trash.TRASH_IN_ROOT
)
self.assertEqual(self.backend.available(path, [self.tmp]), trash.TRASH_IN_ROOT)
self.assertIsNone(self.backend.available(path, [self.drive]))
self.assertEqual(os.listdir(self.drive), ["output"])
# ── names and the no-replace rename ───────────────────────────────
+372
View File
@@ -0,0 +1,372 @@
"""No route reads a file in a trash folder below its root (#543).
Each route is tried with the trash folder named outright, through a linked file
and through a linked folder, and with a file it must still read as the control.
The Windows spellings run only on Windows, and they do run there: outside a
developer's machine a missing short name or symlink right is a failure.
"""
import ctypes
import os
import unittest
import urllib.parse
from pathlib import Path
from unittest.mock import AsyncMock, patch
try:
from tests.trash_route_audit import (
FILE_READER_CALLS,
KNOWN_FILE_READERS,
ROUTE_AUDIT,
)
from tests.test_api_admin import _write_png
from tests.test_image_delete_routes import ImageDeleteTestCase
except ImportError: # discovered with tests/ as the top-level directory
from trash_route_audit import FILE_READER_CALLS, KNOWN_FILE_READERS, ROUTE_AUDIT
from test_api_admin import _write_png
from test_image_delete_routes import ImageDeleteTestCase
from py.api.duplicate_review import OUTSIDE_SOURCE, file_problem
from py.api.images import media_thumbnail_rel
from py.api.thumbnail_auto import OVER, _quick_source, _thumbnail_source
from utils.media_folders import in_trash_below_roots, in_trash_folder, is_trash_folder
ON_CI = bool(os.environ.get("GITHUB_ACTIONS"))
NAMED = (".Trash-1000/files/x.png", "$RECYCLE.BIN/x.png", "sub/.Trashes/501/x.png")
LINKED = ("link.png", "linkdir/x.png")
def _short_name(path):
"""The 8.3 spelling of *path* (Windows only)."""
buffer = ctypes.create_unicode_buffer(1024)
ctypes.windll.kernel32.GetShortPathNameW(str(path), buffer, 1024)
return buffer.value
class _GateCase(ImageDeleteTestCase):
async def get_application(self):
app = await super().get_application()
self.root = self.output_dir
_write_png(self.root / "kept.png", prompt_text="kept")
for relative in NAMED:
(self.root / relative).parent.mkdir(parents=True, exist_ok=True)
_write_png(self.root / relative, prompt_text="thrown away")
return app
def links(self):
"""Links into the trash folder, or a skip where they cannot be made."""
inside = self.root / ".Trash-1000" / "files"
try:
os.symlink(inside / "x.png", self.root / "link.png")
os.symlink(inside, self.root / "linkdir", target_is_directory=True)
except OSError:
if ON_CI:
raise
self.skipTest("this machine cannot make symlinks")
def query(self):
scope = self.api._gallery_root_scope(self.api._get_all_output_dirs()[0], 0)
return {"root": "0", "root_scope": scope}
def body(self, relative):
return self.target(relative)
async def status(self, method, url, **kwargs):
response = await self.client.request(method, url, **kwargs)
await response.read()
return response.status
async def serve(self, relative):
url = "/prompt_manager/images/serve/" + urllib.parse.quote(relative)
return await self.status("GET", url, params=self.query())
async def inspect(self, relative):
url = "/prompt_manager/images/inspect/" + urllib.parse.quote(relative)
return await self.status("GET", url, params=self.query())
async def rescope(self, relative):
response = await self.client.post(
"/prompt_manager/images/rescope", json={"target": self.body(relative)}
)
return response.status, await response.json()
async def autotag(self, relative):
"""The target the legacy single/apply bodies pick, by path."""
target, error = await self.api._select_autotag_target(
{"root_index": 0, "path": relative}
)
return target, error
async def assert_hidden_everywhere(self, relative):
self.assertEqual(await self.serve(relative), 404)
self.assertEqual(await self.inspect(relative), 404)
self.assertIsNone(self.api._reveal_path(relative, self.root))
status, body = await self.rescope(relative)
self.assertEqual((status, body.get("code")), (404, "file-missing"))
target, error = await self.autotag(relative)
self.assertIsNone(target)
self.assertEqual(error.status, 403)
answer = await self.client.post(
"/prompt_manager/images/link",
json={"prompt_id": self.save("p"), "image_path": str(self.root / relative)},
)
self.assertEqual(answer.status, 404)
class TestTheGate(_GateCase):
async def test_the_control_file_still_reads_on_every_route(self):
self.assertEqual(await self.serve("kept.png"), 200)
self.assertEqual(await self.inspect("kept.png"), 200)
self.assertIsNotNone(self.api._reveal_path("kept.png", self.root))
self.assertEqual((await self.rescope("kept.png"))[0], 200)
target, _error = await self.autotag("kept.png")
self.assertIsNotNone(target)
answer = await self.client.post(
"/prompt_manager/images/link",
json={
"prompt_id": self.save("p"),
"image_path": str(self.root / "kept.png"),
},
)
self.assertEqual(answer.status, 200)
observed = await self.client.post(
"/prompt_manager/image-annotations/observe",
json={"target": self.body("kept.png")},
)
self.assertEqual(observed.status, 200)
added = await self.client.post(
"/prompt_manager/collections/items",
json={"name": "c", "items": [self.body("kept.png")]},
)
self.assertLess(added.status, 300)
async def test_a_named_trash_folder_is_hidden_on_every_route(self):
for relative in NAMED:
with self.subTest(relative=relative):
await self.assert_hidden_everywhere(relative)
async def test_a_linked_file_or_folder_is_hidden_on_every_route(self):
self.links()
for relative in LINKED:
with self.subTest(relative=relative):
await self.assert_hidden_everywhere(relative)
async def test_serving_by_id_autotag_by_id_and_a_prompts_media(self):
self.links()
roots = self.api._get_all_output_dirs()
for relative in (*NAMED, *LINKED):
with self.subTest(relative=relative):
image_id = self.api.db.link_image_to_prompt(
str(self.save(f"linked {relative}")), str(self.root / relative)
)
url = f"/prompt_manager/images/{image_id}/file"
self.assertEqual(await self.status("GET", url), 404)
target, error = await self.api._select_autotag_target(
{"image_id": image_id}
)
self.assertIsNone(target)
self.assertEqual(error.status, 403)
records = [{"image_path": str(self.root / relative)}]
self.assertEqual(self.api._library_media(records, roots), [])
kept = [{"image_path": str(self.root / "kept.png")}]
self.assertEqual(len(self.api._library_media(kept, roots)), 1)
async def test_annotations_refuse_a_trash_path_and_a_link(self):
self.links()
url = "/prompt_manager/image-annotations/observe"
for relative in NAMED:
with self.subTest(relative=relative):
body = {"target": self.body(relative)}
self.assertEqual(await self.status("POST", url, json=body), 404)
for relative in LINKED:
with self.subTest(relative=relative):
body = {"target": self.body(relative)}
status = await self.status("POST", url, json=body)
self.assertGreaterEqual(status, 400)
self.assertLess(status, 500)
async def test_collections_refuse_a_trash_path(self):
for relative in NAMED:
with self.subTest(relative=relative):
answer = await self.client.post(
"/prompt_manager/collections/items",
json={"name": "c", "items": [self.body(relative)]},
)
self.assertEqual(answer.status, 404)
async def test_the_exact_path_listing_does_not_name_it(self):
self.links()
for relative in (*NAMED, *LINKED):
with self.subTest(relative=relative):
response = await self.client.get(
"/prompt_manager/images/output",
params={"mode": "workspace", "path": relative},
)
page = await response.json()
self.assertEqual(page.get("images", []), [])
async def test_no_preview_is_made_through_a_linked_folder(self):
self.links()
preview = Path(media_thumbnail_rel(Path("linkdir/x.png"))).parts
self.assertIsNone(_thumbnail_source(self.root, preview))
kept = Path(media_thumbnail_rel(Path("kept.png"))).parts
self.assertIsNotNone(_thumbnail_source(self.root, kept))
async def test_the_preview_fallback_past_the_cap_is_gated_too(self):
# The gate's OVER branch looks the source up by name and serves it.
self.links()
gate = self.api._preview_gate()
with patch.object(gate, "run", AsyncMock(return_value=OVER)):
linked = media_thumbnail_rel(Path("linkdir/x.png"))
self.assertEqual(await self.serve(linked), 404)
kept = media_thumbnail_rel(Path("kept.png"))
self.assertEqual(await self.serve(kept), 200)
self.assertIsNone(
_quick_source(
self.root, Path(media_thumbnail_rel(Path("linkdir/x.png"))).parts
)
)
async def test_the_legacy_duplicate_delete_never_unlinks_from_a_trash(self):
self.links()
for relative in (*NAMED, *LINKED):
with self.subTest(relative=relative):
reason = self.api._delete_one_duplicate(relative, self.root)
self.assertEqual(reason, "it is in a trash folder")
self.assertTrue((self.root / relative).exists())
async def test_a_link_inside_a_trash_folder_is_never_removed(self):
self.links()
link = self.root / ".Trash-1000" / "files" / "back.png"
os.symlink(self.root / "kept.png", link)
reason = self.api._delete_one_duplicate(".Trash-1000/files/back.png", self.root)
self.assertEqual(reason, "it is in a trash folder")
self.assertTrue(link.is_symlink())
self.assertTrue((self.root / "kept.png").exists())
async def test_the_legacy_delete_removes_the_link_itself_and_its_own_preview(self):
self.links()
os.symlink(self.root / "kept.png", self.root / "alias.png")
thumbs = {}
for name in ("kept.png", "alias.png"):
thumbs[name] = self.root / media_thumbnail_rel(Path(name))
thumbs[name].parent.mkdir(parents=True, exist_ok=True)
thumbs[name].write_bytes(b"preview")
self.assertIsNone(self.api._delete_one_duplicate("alias.png", self.root))
self.assertFalse((self.root / "alias.png").is_symlink())
self.assertTrue((self.root / "kept.png").is_file())
self.assertFalse(thumbs["alias.png"].exists())
self.assertTrue(thumbs["kept.png"].exists())
async def test_a_linked_folder_out_of_the_root_removes_nothing_there(self):
self.links()
outside = self.outside_dir / "elsewhere"
outside.mkdir(parents=True)
os.symlink(self.root / "kept.png", outside / "back.png")
os.symlink(outside, self.root / "out", target_is_directory=True)
reason = self.api._delete_one_duplicate("out/back.png", self.root)
self.assertEqual(reason, "outside output directory")
self.assertTrue((outside / "back.png").is_symlink())
async def test_a_duplicate_removal_review_refuses_a_trash_folder(self):
path = self.root / ".Trash-1000" / "files" / "x.png"
problem = file_problem(
path, self.root, None, path.stat().st_size, hash_check=False
)
self.assertEqual(problem, OUTSIDE_SOURCE)
async def test_a_source_named_like_a_trash_folder_still_reads(self):
source = self.root / ".Trash-77"
source.mkdir()
_write_png(source / "mine.png", prompt_text="mine")
self.api._get_all_output_dirs = lambda: [source]
self.assertEqual(await self.serve("mine.png"), 200)
self.assertEqual(await self.inspect("mine.png"), 200)
self.assertEqual((await self.rescope("mine.png"))[0], 200)
self.assertIsNotNone((await self.autotag("mine.png"))[0])
async def test_delete_still_moves_a_listed_file(self):
await self.review(["kept.png"])
_plan, job = await self.delete(["kept.png"], "trash")
self.assertEqual(job.detail()["counters"]["trashed"], 1)
self.assertFalse((self.root / "kept.png").exists())
class TestPredicates(unittest.TestCase):
def test_trailing_dots_and_spaces_are_ignored(self):
for name in ("$RECYCLE.BIN.", "$recycle.bin ", ".Trash-1000. ", ".Trashes..."):
self.assertTrue(is_trash_folder(name), name)
for name in ("trash.", "...", " ", ".Trash-x."):
self.assertFalse(is_trash_folder(name), name)
def test_either_separator(self):
self.assertTrue(in_trash_folder("a\\.Trash\\b.png"))
self.assertFalse(in_trash_folder(".Trash"))
def test_a_path_no_root_visibly_holds_fails_closed(self):
self.assertTrue(in_trash_below_roots("/x/.Trash-1/files/a.png", ["/other"]))
self.assertFalse(in_trash_below_roots("/x/a.png", ["/other"]))
@unittest.skipUnless(os.name == "nt", "Windows spellings of one folder")
class TestWindowsSpellings(_GateCase):
def spellings(self):
spelled = [
"$RECYCLE.BIN./x.png",
"$recycle.bin/x.png",
".TRASH-1000/files/x.png",
".Trash-1000./files/x.png",
]
short = _short_name(self.root / ".Trash-1000")
if os.path.basename(short).casefold() == ".trash-1000":
if ON_CI:
self.fail("the job's work disk gave no 8.3 short name")
self.skipTest("this disk makes no 8.3 short names")
spelled.append(os.path.basename(short) + "/files/x.png")
return spelled
async def test_every_spelling_reaches_the_file_and_is_hidden(self):
for relative in self.spellings():
with self.subTest(relative=relative):
self.assertTrue((self.root / relative).is_file(), "spelling is real")
await self.assert_hidden_everywhere(relative)
async def test_a_folder_spelled_with_a_trailing_space_is_hidden_by_name(self):
# Measured on the Windows job (run 41123443497): unlike a trailing dot,
# this spelling does not reach the file through a longer path. The name
# check still answers for it, whatever a volume does.
await self.assert_hidden_everywhere("$RECYCLE.BIN /x.png")
async def test_a_root_spelled_in_another_case_does_not_fail_open(self):
inside = self.root / ".Trash-1000" / "files" / "x.png"
self.assertTrue(in_trash_below_roots(inside, [str(self.root).upper()]))
kept = self.root / "kept.png"
self.assertFalse(in_trash_below_roots(kept, [str(self.root).upper()]))
class TestEveryRouteIsClassified(_GateCase):
async def test_a_new_route_must_say_how_it_treats_media_files(self):
registered = {
f"{route.method} {route.resource.canonical}"
for route in self.app.router.routes()
if route.method != "HEAD"
and route.resource is not None
and route.resource.canonical.startswith("/prompt_manager")
}
audited = {route for routes in ROUTE_AUDIT.values() for route in routes}
self.assertEqual(
registered ^ audited,
set(),
"add the route to tests/trash_route_audit.py with its media check",
)
def test_a_file_reader_in_the_api_layer_is_a_known_one(self):
# A new way to open a media file must be looked at for the same gate.
api = Path(__file__).resolve().parent.parent / "py" / "api"
found = set()
for module in api.glob("*.py"):
text = module.read_text(encoding="utf-8")
found.update(
f"{module.name}:{call}" for call in FILE_READER_CALLS if call in text
)
self.assertEqual(found ^ KNOWN_FILE_READERS, set())
+516
View File
@@ -0,0 +1,516 @@
"""An output folder that is itself a mount point (#543).
The freedesktop per-drive trash is then inside the output root. Only the mount's
own ``.Trash-$uid`` and ``.Trash/$uid`` are allowed, only for a root that is
exactly the mount root; every walker and media route ignores them.
Every class runs on a disposable tree with a fake mount table. With
``PM_TRASH_MOUNT=<empty folder that is a real mount>`` the same tests run on that
real mount (``.github/workflows/portability.yml``, ``native-trash-linux``, or
locally ``unshare -Urm`` over a tmpfs); that folder is the only real place they
touch, and the fake-table-only cases skip.
"""
import asyncio
import errno
import os
import shutil
import stat
import sys
import tempfile
import unittest
import urllib.parse
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.disk_waits import DISK_BOUND_SECONDS
from tests.test_api_admin import _write_png
from tests.test_image_delete_routes import ImageDeleteTestCase
except ImportError: # discovered with tests/ as the top-level directory
from disk_waits import DISK_BOUND_SECONDS
from test_api_admin import _write_png
from test_image_delete_routes import ImageDeleteTestCase
from py.api import images as images_module # noqa: E402
from py.api.admin import _collect_media_files # noqa: E402
from py.api.autotag_routes import AutotagRoutesMixin # noqa: E402
from utils import trash # noqa: E402
from utils.image_monitor import ImageGenerationHandler # noqa: E402
POSIX = os.name == "posix" and sys.platform != "darwin"
REAL_MOUNT = os.environ.get("PM_TRASH_MOUNT")
UID = os.getuid() if hasattr(os, "getuid") else -1
def _empty(folder):
"""Remove what a test left inside *folder*, never the folder itself."""
for name in os.listdir(folder):
path = os.path.join(folder, name)
if os.path.isdir(path) and not os.path.islink(path):
os.chmod(path, 0o700)
shutil.rmtree(path)
else:
os.unlink(path)
def restore_as_a_file_manager_does(top, trash_dir, name):
"""Put back one trashed file from its ``.trashinfo``, as the spec's readers do."""
info_path = os.path.join(trash_dir, "info", name + ".trashinfo")
with open(info_path, encoding="utf-8") as handle:
lines = handle.read().splitlines()
recorded = next(line[5:] for line in lines if line.startswith("Path="))
relative = os.fsdecode(urllib.parse.unquote_to_bytes(recorded))
# A top-directory trash records a path relative to the mount (spec 1.0).
assert not os.path.isabs(relative), recorded
destination = os.path.join(top, relative)
os.makedirs(os.path.dirname(destination), exist_ok=True)
os.rename(os.path.join(trash_dir, "files", name), destination)
os.unlink(info_path)
return destination
@unittest.skipUnless(POSIX, "freedesktop trash is the Linux/BSD backend")
class MountRootBackendTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="pm-trash-root-")
self.addCleanup(shutil.rmtree, self.tmp, True)
self.data_home = os.path.join(self.tmp, "xdg")
os.makedirs(self.data_home)
if REAL_MOUNT:
self.assertTrue(os.path.ismount(REAL_MOUNT), "PM_TRASH_MOUNT is no mount")
self.drive = os.path.realpath(REAL_MOUNT)
self.addCleanup(_empty, self.drive)
self.backend = trash.FreedesktopBackend(data_home=self.data_home)
else:
self.drive = os.path.join(self.tmp, "drive")
os.makedirs(self.drive)
self.backend = self._fake_table()
self.dash = os.path.join(self.drive, f".Trash-{UID}")
def _fake_table(self, *extra, uid=None):
mounts = ["/", os.path.realpath(self.drive), *extra]
return trash.FreedesktopBackend(
data_home=self.data_home, mounts=lambda: list(mounts), uid=uid
)
def _file(self, relative="a.png", content=b"png-bytes"):
path = os.path.join(self.drive, relative)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as handle:
handle.write(content)
return path
def _assert_refused(self, path, roots, code):
self.assertEqual(self.backend.available(path, roots), code)
with self.assertRaises(trash.TrashRefused) as caught:
self.backend.move(path, roots)
self.assertEqual(caught.exception.code, code)
self.assertTrue(os.path.lexists(path))
def _sticky_shared(self):
shared = os.path.join(self.drive, ".Trash")
os.mkdir(shared)
os.chmod(shared, 0o1777)
return shared
# ── the two allowed folders ───────────────────────────────────────
def test_a_mount_root_output_uses_its_dash_uid_trash(self):
path = self._file()
identity = os.lstat(path)
self.assertIsNone(self.backend.available(path, [self.drive]))
self.assertEqual(os.listdir(self.drive), ["a.png"], "available wrote nothing")
label = self.backend.move(path, [self.drive])
self.assertEqual(label, f".Trash-{UID} on its drive")
self.assertFalse(os.path.lexists(path))
moved = os.lstat(os.path.join(self.dash, "files", "a.png"))
self.assertEqual(
(moved.st_dev, moved.st_ino), (identity.st_dev, identity.st_ino)
)
self.assertEqual(stat.S_IMODE(os.lstat(self.dash).st_mode), 0o700)
def test_a_mount_root_output_uses_a_sticky_shared_trash_first(self):
shared = self._sticky_shared()
path = self._file()
label = self.backend.move(path, [self.drive])
self.assertEqual(label, f"{os.path.join('.Trash', str(UID))} on its drive")
self.assertTrue(
os.path.exists(os.path.join(shared, str(UID), "files", "a.png"))
)
self.assertFalse(os.path.lexists(self.dash))
def test_a_nested_output_root_beside_the_mount_root_is_fine(self):
path = self._file("sub/a.png")
roots = [self.drive, os.path.join(self.drive, "sub")]
self.assertIsNone(self.backend.available(path, roots))
self.backend.move(path, roots)
self.assertTrue(os.path.exists(os.path.join(self.dash, "files", "a.png")))
# ── the spec's checks stay ────────────────────────────────────────
def test_a_shared_trash_without_the_sticky_bit_is_passed_over(self):
shared = os.path.join(self.drive, ".Trash")
os.mkdir(shared, 0o777)
os.chmod(shared, 0o777)
path = self._file()
self.backend.move(path, [self.drive])
self.assertEqual(os.listdir(shared), [])
self.assertTrue(os.path.exists(os.path.join(self.dash, "files", "a.png")))
def test_a_shared_trash_that_is_a_link_is_passed_over(self):
real = os.path.join(self.tmp, "sticky")
os.mkdir(real)
os.chmod(real, 0o1777)
os.symlink(real, os.path.join(self.drive, ".Trash"))
path = self._file()
self.backend.move(path, [self.drive])
self.assertEqual(os.listdir(real), [])
self.assertTrue(os.path.exists(os.path.join(self.dash, "files", "a.png")))
def test_a_dash_uid_link_into_the_root_is_refused(self):
os.mkdir(os.path.join(self.drive, "images"))
os.symlink(os.path.join(self.drive, "images"), self.dash)
path = self._file("images/a.png")
self._assert_refused(path, [self.drive], trash.TRASH_IN_ROOT)
self.assertEqual(os.listdir(os.path.join(self.drive, "images")), ["a.png"])
def test_a_dash_uid_link_out_of_the_root_is_refused(self):
elsewhere = os.path.join(self.tmp, "elsewhere")
os.mkdir(elsewhere)
os.symlink(elsewhere, self.dash)
path = self._file()
self._assert_refused(path, [self.drive], trash.NO_TRASH)
self.assertEqual(os.listdir(elsewhere), [])
def test_a_uid_link_in_the_shared_trash_is_never_followed(self):
shared = self._sticky_shared()
os.mkdir(os.path.join(self.drive, "images"))
os.symlink(os.path.join(self.drive, "images"), os.path.join(shared, str(UID)))
path = self._file("images/a.png")
self._assert_refused(path, [self.drive], trash.TRASH_IN_ROOT)
def test_a_files_folder_that_is_a_link_is_refused(self):
os.mkdir(self.dash, 0o700)
elsewhere = os.path.join(self.tmp, "elsewhere")
os.mkdir(elsewhere)
os.symlink(elsewhere, os.path.join(self.dash, "files"))
path = self._file()
with self.assertRaises(trash.TrashRefused) as caught:
self.backend.move(path, [self.drive])
self.assertEqual(caught.exception.code, trash.NO_TRASH)
self.assertTrue(os.path.exists(path))
self.assertEqual(os.listdir(elsewhere), [])
@unittest.skipIf(REAL_MOUNT, "needs the fake mount table")
def test_a_trash_folder_owned_by_someone_else_is_refused(self):
other = UID + 1
os.mkdir(os.path.join(self.drive, f".Trash-{other}"), 0o700)
backend = self._fake_table(uid=other)
path = self._file()
self.assertEqual(backend.available(path, [self.drive]), trash.NO_TRASH)
self.assertTrue(os.path.exists(path))
def test_a_rename_across_drives_is_refused_and_nothing_is_copied(self):
def cross(*_args):
raise OSError(errno.EXDEV, "cross-device")
backend = trash.FreedesktopBackend(
data_home=self.data_home,
mounts=lambda: ["/", os.path.realpath(self.drive)],
rename=cross,
)
path = self._file()
with self.assertRaises(trash.TrashRefused) as caught:
backend.move(path, [self.drive])
self.assertEqual(caught.exception.code, trash.NO_TRASH)
self.assertTrue(os.path.exists(path))
self.assertEqual(os.listdir(os.path.join(self.dash, "files")), [])
self.assertEqual(os.listdir(os.path.join(self.dash, "info")), [])
# ── any other trash inside a root stays refused ───────────────────
def test_a_root_above_the_mount_is_refused(self):
path = self._file()
above = os.path.dirname(self.drive)
self._assert_refused(path, [above], trash.TRASH_IN_ROOT)
self._assert_refused(path, [self.drive, above], trash.TRASH_IN_ROOT)
self.assertFalse(os.path.lexists(self.dash))
def test_a_source_named_like_the_trash_folder_is_refused(self):
source = self.dash
path = self._file(f".Trash-{UID}/a.png")
self._assert_refused(path, [source], trash.TRASH_IN_ROOT)
self._assert_refused(path, [self.drive, source], trash.TRASH_IN_ROOT)
def test_a_source_inside_the_trash_folder_is_refused(self):
source = os.path.join(self.dash, "files")
os.makedirs(source)
path = self._file()
self._assert_refused(path, [self.drive, source], trash.TRASH_IN_ROOT)
self.assertEqual(os.listdir(source), [])
def test_a_source_that_is_the_shared_trash_is_refused(self):
shared = self._sticky_shared()
path = self._file()
self._assert_refused(path, [self.drive, shared], trash.TRASH_IN_ROOT)
@unittest.skipIf(REAL_MOUNT, "needs the fake mount table")
def test_a_mount_nested_under_the_root_is_refused(self):
# The file's mount is not the root: its trash is "any other location".
inner = os.path.join(self.drive, "inner")
os.mkdir(inner)
backend = self._fake_table(os.path.realpath(inner))
path = self._file("inner/a.png")
self.assertEqual(backend.available(path, [self.drive]), trash.TRASH_IN_ROOT)
# ...and the same inner mount as its own root is fine.
self.assertIsNone(backend.available(path, [inner]))
@unittest.skipIf(REAL_MOUNT, "needs the fake mount table")
def test_the_home_trash_inside_a_mount_root_output_stays_refused(self):
backend = trash.FreedesktopBackend(
data_home=os.path.join(self.drive, "xdg"),
mounts=lambda: ["/", os.path.realpath(self.drive)],
)
path = self._file()
self.assertEqual(backend.available(path, [self.drive]), trash.TRASH_IN_ROOT)
# ── restoring ─────────────────────────────────────────────────────
def test_the_recorded_path_is_relative_to_the_mount(self):
path = self._file("sub dir/b c%é.png")
self.backend.move(path, [self.drive])
with open(
os.path.join(self.dash, "info", "b c%é.png.trashinfo"), encoding="utf-8"
) as handle:
info = handle.read()
self.assertIn("Path=sub%20dir/b%20c%25%C3%A9.png\n", info)
def test_a_restored_file_returns_to_its_place_with_its_bytes(self):
path = self._file("sub/a.png", b"original")
identity = os.lstat(path).st_ino
self.backend.move(path, [self.drive])
back = restore_as_a_file_manager_does(self.drive, self.dash, "a.png")
self.assertEqual(back, path)
self.assertEqual(os.lstat(path).st_ino, identity)
with open(path, "rb") as handle:
self.assertEqual(handle.read(), b"original")
self.assertEqual(os.listdir(os.path.join(self.dash, "files")), [])
@unittest.skipUnless(POSIX, "freedesktop trash is the Linux/BSD backend")
class MountRootLayoutTests(ImageDeleteTestCase):
"""The real backend over the routes: nothing lists the trash, restore relinks."""
async def get_application(self):
app = await super().get_application()
self.data_home = Path(self.tmpdir) / "xdg"
self.data_home.mkdir()
if REAL_MOUNT:
self.assertTrue(os.path.ismount(REAL_MOUNT), "PM_TRASH_MOUNT is no mount")
self.output_dir = Path(os.path.realpath(REAL_MOUNT))
self.addCleanup(_empty, str(self.output_dir))
self.api._get_all_output_dirs = lambda: [self.output_dir]
mounts = None
else:
drive = os.path.realpath(self.output_dir)
mounts = lambda: ["/", drive] # noqa: E731
trash.BACKEND = trash.FreedesktopBackend(
data_home=str(self.data_home), mounts=mounts
)
self.trash_dir = self.output_dir / f".Trash-{UID}"
return app
async def _trashed_one(self):
"""``kept.png`` stays; ``gone.png`` was moved to the drive's trash."""
_write_png(self.output_dir / "kept.png", prompt_text="a kept prompt")
_write_png(self.output_dir / "gone.png", prompt_text="a thrown prompt")
_plan, job = await self.delete(["gone.png"], "trash")
self.assertEqual(job.detail()["counters"]["trashed"], 1, job.detail())
self.assertTrue((self.trash_dir / "files" / "gone.png").is_file())
self.assertFalse((self.output_dir / "gone.png").exists())
async def _json(self, method, url, **kwargs):
response = await self.client.request(method, url, **kwargs)
self.assertLess(response.status, 500, await response.text())
return await response.json()
async def _scan(self, scope="all"):
answer = await self._json(
"POST", "/prompt_manager/jobs/scan", json={"scope": scope}
)
task = self.api._scan_job.task
await asyncio.wait_for(asyncio.shield(task), DISK_BOUND_SECONDS)
return answer
def _linked_paths(self):
return {
os.path.basename(row[0])
for row in self._rows("SELECT image_path FROM generated_images")
}
def _rows(self, sql):
import sqlite3
conn = sqlite3.connect(self.api.db.model.db_path)
try:
return conn.execute(sql).fetchall()
finally:
conn.close()
async def test_the_review_can_move_files_on_a_mount_root_output(self):
_write_png(self.output_dir / "a.png", prompt_text="p")
plan = await self.review(["a.png"])
self.assertEqual(plan["trash"], {"count": 1})
self.assertEqual(plan["trash_refused"]["count"], 0)
self.assertFalse(self.trash_dir.exists(), "the review wrote nothing")
async def test_refresh_listing_and_legacy_gallery_ignore_the_trash(self):
await self._trashed_one()
self.assertEqual(await self.listed(), ["kept.png"])
rescan = await self._json("POST", "/prompt_manager/gallery/rescan")
self.assertNotIn(".Trash", str(rescan))
legacy = await self._json("GET", "/prompt_manager/images/output")
self.assertNotIn(".Trash", str(legacy))
folders = await self._json("GET", "/prompt_manager/gallery/subfolders")
self.assertNotIn(".Trash", str(folders))
async def test_scan_folder_and_its_walkers_ignore_the_trash(self):
await self._trashed_one()
await self._scan()
self.assertEqual(self._linked_paths(), {"kept.png"})
collected = _collect_media_files([self.output_dir], [".png"])
self.assertEqual([Path(p).name for p in collected], ["kept.png"])
found, _truncated = AutotagRoutesMixin._collect_scan_files(self.output_dir)
self.assertEqual([p.name for p in found], ["kept.png"])
async def test_thumbnail_plans_ignore_the_trash(self):
await self._trashed_one()
thumbs = self.output_dir / "thumbnails"
for mode in ("missing", "rebuild"):
plan = self.api._plan_thumbnails(self.output_dir, thumbs, mode=mode)
sources = [src.name for src, _dst, _video in plan["targets"]]
self.assertEqual(sources, ["kept.png"])
async def test_the_autotag_listing_ignores_the_trash(self):
await self._trashed_one()
with patch.object(
self.api, "_find_comfyui_output_dir", return_value=str(self.output_dir)
):
body = await self._json("GET", "/prompt_manager/scan_output_dir")
self.assertEqual([i["filename"] for i in body["images"]], ["kept.png"])
async def test_the_monitor_ignores_the_trash_with_nested_roots(self):
await self._trashed_one()
monitor = ImageGenerationHandler.__new__(ImageGenerationHandler)
monitor.supported_extensions = (".png",)
root = str(self.output_dir)
monitor.watched_roots = (root, os.path.join(root, "sub"))
inside = os.path.join(str(self.trash_dir), "files", "gone.png")
self.assertFalse(monitor.is_image_file(inside))
self.assertTrue(monitor.is_image_file(os.path.join(root, "new.png")))
self.assertTrue(monitor.is_image_file(os.path.join(root, "sub", "n.png")))
async def test_a_source_below_a_trash_named_folder_lists_on_its_own(self):
# The Codex P2 from #502: a source named like a trash folder stays a
# source, while the outer root does not list it.
source = self.output_dir / ".Trash-77"
source.mkdir()
_write_png(source / "mine.png", prompt_text="mine")
_write_png(self.output_dir / "kept.png", prompt_text="kept")
outer = [
Path(e.path).name for e in images_module._walk_media(self.output_dir, 100)
]
self.assertEqual(outer, ["kept.png"])
inner = [Path(e.path).name for e in images_module._walk_media(source, 100)]
self.assertEqual(inner, ["mine.png"])
monitor = ImageGenerationHandler.__new__(ImageGenerationHandler)
monitor.supported_extensions = (".png",)
monitor.watched_roots = (str(self.output_dir), str(source))
self.assertTrue(monitor.is_image_file(str(source / "new.png")))
self.assertFalse(
monitor.is_image_file(str(self.output_dir / ".Trash-1000" / "x.png"))
)
async def test_the_media_routes_do_not_serve_a_trashed_file(self):
await self._trashed_one()
relative = f".Trash-{UID}/files/gone.png"
roots = self.api._get_all_output_dirs()
scope = self.api._gallery_root_scope(roots[0], 0)
query = {"root": "0", "root_scope": scope}
served = await self.client.get(
f"/prompt_manager/images/serve/{relative}", params=query
)
self.assertEqual(served.status, 404)
preview = await self.client.get(
f"/prompt_manager/images/serve/thumbnails/{relative}", params=query
)
self.assertEqual(preview.status, 404)
self.assertFalse((self.output_dir / "thumbnails" / f".Trash-{UID}").exists())
inspected = await self.client.get(
f"/prompt_manager/images/inspect/{relative}", params=query
)
self.assertEqual(inspected.status, 404)
self.assertIsNone(self.api._reveal_path(relative, roots[0]))
self.assertIsNotNone(self.api._reveal_path("kept.png", roots[0]))
async def test_a_record_pointing_into_the_trash_is_not_served(self):
await self._trashed_one()
inside = self.trash_dir / "files" / "gone.png"
image_id = self.api.db.link_image_to_prompt(
str(self.save("linked by hand")), str(inside)
)
response = await self.client.get(f"/prompt_manager/images/{image_id}/file")
self.assertEqual(response.status, 404)
async def test_a_trashed_file_cannot_be_linked_or_listed_under_a_prompt(self):
await self._trashed_one()
inside = self.trash_dir / "files" / "gone.png"
prompt_id = self.save("linked by hand")
refused = await self.client.post(
"/prompt_manager/images/link",
json={"prompt_id": prompt_id, "image_path": str(inside)},
)
self.assertEqual(refused.status, 404)
self.assertEqual(self._linked_paths(), set())
# A record from before this check still lists nothing.
self.api.db.link_image_to_prompt(str(prompt_id), str(inside))
page = self.api._library_media(
self._rows_as_records(), self.api._get_all_output_dirs()
)
self.assertEqual(page, [])
def _rows_as_records(self):
rows = self._rows("SELECT image_path FROM generated_images")
return [{"image_path": row[0]} for row in rows]
async def test_a_source_named_like_a_trash_folder_is_still_served(self):
source = self.output_dir / ".Trash-77"
source.mkdir()
_write_png(source / "mine.png", prompt_text="mine")
self.api._get_all_output_dirs = lambda: [source]
scope = self.api._gallery_root_scope(source, 0)
served = await self.client.get(
"/prompt_manager/images/serve/mine.png",
params={"root": "0", "root_scope": scope},
)
self.assertEqual(served.status, 200)
async def test_a_restored_file_lists_and_links_as_before(self):
await self._trashed_one()
await self._scan()
self.assertEqual(self._linked_paths(), {"kept.png"})
back = restore_as_a_file_manager_does(
str(self.output_dir), str(self.trash_dir), "gone.png"
)
self.assertEqual(back, str(self.output_dir / "gone.png"))
self.assertEqual(sorted(await self.listed()), ["gone.png", "kept.png"])
await self._scan("new")
self.assertEqual(self._linked_paths(), {"gone.png", "kept.png"})
prompts = self._rows(
"SELECT p.text FROM generated_images g JOIN prompts p ON p.id = g.prompt_id"
" WHERE g.image_path LIKE '%gone.png'"
)
self.assertEqual(prompts, [("a thrown prompt",)])
+212
View File
@@ -0,0 +1,212 @@
"""Every /prompt_manager route and how it treats a file in a trash folder (#543).
``tests/test_trash_gate_routes.py`` fails when a route is missing here, so a new
route has to choose a group. ``FILE_READER_CALLS`` is the same idea for code that
opens files in ``py/api``.
"""
FILE_READER_CALLS = ("FileResponse(", "Image.open(", "os.open(", "read_bytes(")
KNOWN_FILE_READERS = frozenset(
{
"history_current.py:os.open(",
"images.py:FileResponse(",
"images.py:Image.open(",
"images.py:os.open(",
"__init__.py:Image.open(",
"logging_routes.py:os.open(",
"thumbnail_auto.py:FileResponse(",
"thumbnail_encode.py:Image.open(",
}
)
ROUTE_AUDIT = {
"gate: _media_access_error, resolved": (
"GET /prompt_manager/images/{image_id}/file",
"GET /prompt_manager/images/inspect/{image_path}",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/images/link",
"POST /prompt_manager/images/rescope",
"GET /prompt_manager/library/{positive_id}/images",
"GET /prompt_manager/library/{positive_id}",
"GET /prompt_manager/library",
),
"in_trash_below_roots, resolved": (
"GET /prompt_manager/images/serve/{filepath}",
"POST /prompt_manager/autotag/single",
"POST /prompt_manager/autotag/apply",
"GET /prompt_manager/history/runs/{run_id}/outputs",
"GET /prompt_manager/history/runs/{run_id}/output/current/media",
"GET /prompt_manager/history/runs/{run_id}/output/current/thumbnail",
"GET /prompt_manager/history/runs/{run_id}/output/current/inspection",
"POST /prompt_manager/delete_duplicate_images",
),
"media_target by name, then the capture alias check or listed files": (
"POST /prompt_manager/image-annotations/observe",
"POST /prompt_manager/image-annotations/suggest",
"PUT /prompt_manager/image-annotations/tags",
"GET /prompt_manager/image-annotations/capture/{token}",
"DELETE /prompt_manager/image-annotations/capture/{token}",
"POST /prompt_manager/jobs/image-tags",
"POST /prompt_manager/collections/items",
"DELETE /prompt_manager/collections/items",
"GET /prompt_manager/collections/membership",
"PUT /prompt_manager/images/category",
"DELETE /prompt_manager/images/category",
"POST /prompt_manager/review/items/{item_id}/decision",
"POST /prompt_manager/review/items/{item_id}/link",
),
"walker: utils/media_folders (listed files only)": (
"GET /prompt_manager/images/output",
"POST /prompt_manager/gallery/rescan",
"GET /prompt_manager/gallery/subfolders",
"PUT /prompt_manager/images/favorite",
"DELETE /prompt_manager/images/favorite",
"GET /prompt_manager/images/categories",
"POST /prompt_manager/images/delete/preview",
"POST /prompt_manager/images/removed/return",
"POST /prompt_manager/scan",
"POST /prompt_manager/jobs/scan",
"GET /prompt_manager/scan_duplicates",
"POST /prompt_manager/scan_duplicates/stream",
"POST /prompt_manager/jobs/duplicates",
"GET /prompt_manager/maintenance/duplicates/{job_id}",
"GET /prompt_manager/scan_output_dir",
"POST /prompt_manager/autotag/start",
"POST /prompt_manager/autotag/batch/preview",
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/thumbnails",
"GET /prompt_manager/images/thumbnails/plan",
"GET /prompt_manager/images/thumbnails/status",
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
"GET /prompt_manager/diagnostics",
),
"record text only, reads no file; its media is served through the gate": (
"GET /prompt_manager/images/all",
"GET /prompt_manager/images/recent",
"GET /prompt_manager/images/search",
"GET /prompt_manager/images/prompt/{image_path}",
"GET /prompt_manager/prompts/{prompt_id}/images",
"DELETE /prompt_manager/images/{image_id}",
"GET /prompt_manager/recent",
"GET /prompt_manager/search",
"GET /prompt_manager/export",
"GET /prompt_manager/library/export",
"GET /prompt_manager/history/runs/{run_id}",
"GET /prompt_manager/review/items",
"GET /prompt_manager/review/items/{item_id}",
"POST /prompt_manager/maintenance/preview",
"POST /prompt_manager/maintenance/apply",
),
"no media file": (
"DELETE /prompt_manager/collections/{collection_id}",
"DELETE /prompt_manager/delete/{prompt_id}",
"DELETE /prompt_manager/pairs/{pair_id}",
"DELETE /prompt_manager/prompts/{prompt_id}/tags",
"DELETE /prompt_manager/restore/preview/{token}",
"DELETE /prompt_manager/saved-views/{view_id}",
"DELETE /prompt_manager/tags/{tag_name}",
"GET /prompt_manager/admin",
"GET /prompt_manager/autotag/models",
"GET /prompt_manager/backup",
"GET /prompt_manager/backup/status",
"GET /prompt_manager/categories",
"GET /prompt_manager/collections",
"GET /prompt_manager/gallery",
"GET /prompt_manager/gallery.html",
"GET /prompt_manager/jobs",
"GET /prompt_manager/jobs/{job_id}",
"GET /prompt_manager/jobs/{job_id}/events",
"GET /prompt_manager/jobs/{job_id}/report",
"GET /prompt_manager/js/{filepath}",
"GET /prompt_manager/lib/{filepath}",
"GET /prompt_manager/library/options",
"GET /prompt_manager/library/{positive_id}/history",
"GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest",
"GET /prompt_manager/logs",
"GET /prompt_manager/logs/config",
"GET /prompt_manager/logs/download/{filename}",
"GET /prompt_manager/logs/files",
"GET /prompt_manager/logs/stats",
"GET /prompt_manager/lora/detect",
"GET /prompt_manager/lora/status",
"GET /prompt_manager/lora/trigger-words",
"GET /prompt_manager/pairs",
"GET /prompt_manager/pairs/export",
"GET /prompt_manager/pairs/{pair_id}",
"GET /prompt_manager/review/events",
"GET /prompt_manager/saved-views",
"GET /prompt_manager/scan/status",
"GET /prompt_manager/scan_duplicates/status",
"GET /prompt_manager/settings",
"GET /prompt_manager/stats",
"GET /prompt_manager/subfolders",
"GET /prompt_manager/system/diagnostics",
"GET /prompt_manager/system/health",
"GET /prompt_manager/system/library",
"GET /prompt_manager/system/overview",
"GET /prompt_manager/system/statistics",
"GET /prompt_manager/tags",
"GET /prompt_manager/tags/filter",
"GET /prompt_manager/tags/stats",
"GET /prompt_manager/tags/suggest",
"GET /prompt_manager/tags/{tag_name}/prompts",
"GET /prompt_manager/test",
"GET /prompt_manager/vocabulary/categories",
"GET /prompt_manager/vocabulary/tags",
"GET /prompt_manager/vocabulary/tags/{tag_id}/impact",
"GET /prompt_manager/web",
"GET /prompt_manager/workspace",
"PATCH /prompt_manager/collections/{collection_id}",
"PATCH /prompt_manager/library/{positive_id}",
"PATCH /prompt_manager/pairs/{pair_id}",
"PATCH /prompt_manager/saved-views/{view_id}",
"PATCH /prompt_manager/vocabulary/tags/{tag_id}",
"POST /prompt_manager/autotag/download/{model_type}",
"POST /prompt_manager/autotag/unload",
"POST /prompt_manager/bulk/category",
"POST /prompt_manager/bulk/delete",
"POST /prompt_manager/bulk/tags",
"POST /prompt_manager/cleanup",
"POST /prompt_manager/diagnostics/test-link",
"POST /prompt_manager/images/clear-thumbnails",
"POST /prompt_manager/jobs/model-download",
"POST /prompt_manager/jobs/{job_id}/cancel",
"POST /prompt_manager/library",
"POST /prompt_manager/library/delete",
"POST /prompt_manager/library/delete/preview",
"POST /prompt_manager/library/restore",
"POST /prompt_manager/library/{positive_id}/variants",
"POST /prompt_manager/logs/config",
"POST /prompt_manager/logs/truncate",
"POST /prompt_manager/lora/enable",
"POST /prompt_manager/lora/import/apply",
"POST /prompt_manager/lora/import/preview",
"POST /prompt_manager/lora/refresh-cache",
"POST /prompt_manager/lora/scan",
"POST /prompt_manager/maintenance",
"POST /prompt_manager/pairs",
"POST /prompt_manager/prompts/tags",
"POST /prompt_manager/prompts/{prompt_id}/tags",
"POST /prompt_manager/restore",
"POST /prompt_manager/restore/apply",
"POST /prompt_manager/restore/preview",
"POST /prompt_manager/save",
"POST /prompt_manager/saved-views",
"POST /prompt_manager/scan/cancel",
"POST /prompt_manager/settings",
"POST /prompt_manager/settings/validate-source",
"POST /prompt_manager/system/health",
"POST /prompt_manager/system/library/keep",
"POST /prompt_manager/system/library/move",
"POST /prompt_manager/tags/merge",
"POST /prompt_manager/vocabulary/categories/apply",
"POST /prompt_manager/vocabulary/categories/preview",
"POST /prompt_manager/vocabulary/tags",
"POST /prompt_manager/vocabulary/tags/{tag_id}/delete",
"PUT /prompt_manager/prompts/{prompt_id}",
"PUT /prompt_manager/prompts/{prompt_id}/rating",
"PUT /prompt_manager/tags/{tag_name}",
),
}
+2 -1
View File
@@ -25,7 +25,8 @@ test('execution script discovers only the new matrix and preserves all 78 origin
// + local Inspect host case (inspect-sender.spec.mjs, 2 projects), + the S10 renderer case (prompt-bridge.spec.mjs, 2 projects)
// + #343 linked text: five node types, PromptSearchList, the pre-#343 workflows, legacy Primitive, moved link,
// paste/clone/undo, wildcard Primitive, subgraph Primitive (text-link.spec.mjs, 2 projects)
assert.match(stock, /Total: 110 tests in 9 files/);
// + #525 the PM button and three nodes' Web UI after renderer switches and reload (prompt-manager.spec.mjs, 2 projects)
assert.match(stock, /Total: 118 tests in 9 files/);
assert.doesNotMatch(stock, /execution-history/);
assert.doesNotMatch(stock, /execution-outputs/);
assert.doesNotMatch(stock, /library-location/); // it starts its own fresh host (#249 S6b)
+10
View File
@@ -112,6 +112,7 @@ COUNTED = (
# The ladder steps whose answer is certain enough to keep a link.
KEEP_SOURCES = ("recorded-run", "inspector")
# What must be unchanged since the dry run for a row to be written.
MANUAL_REASON = "linked by hand in Needs review"
DRIFT_KEYS = (
"action",
"file",
@@ -181,6 +182,13 @@ class Judge:
)
return self.decisions[path]
def _manually_linked(self, image_id):
"""True when the reviewer named this row's prompt in Needs review (#536)."""
return any(
link["provenance"].get("kind") == "manual-link"
for link in self.db.get_image_prompt_pairs(image_id)
)
def row(self, row):
"""The plan entry of one ``generated_images`` row."""
conn = self.db.model.get_connection()
@@ -200,6 +208,8 @@ class Judge:
except OSError:
return {**item, "action": "skip", "reason": "file gone"}
item.update(file_size=stat.st_size, file_mtime_ns=stat.st_mtime_ns)
if self._manually_linked(row["id"]):
return {**item, "action": "skip", "reason": MANUAL_REASON}
decision = self.decide(path, stat.st_size)
action = action_of(decision, prompt_hash)
if action == "skip":
+11
View File
@@ -142,6 +142,14 @@ def certain_prompts(db, handler, path, metadata, output_dir):
return found
def _manually_linked(db, image_id):
"""True when a link of this image records a reviewer's manual choice."""
return any(
link["provenance"].get("kind") == "manual-link"
for link in db.get_image_prompt_pairs(image_id)
)
def judge(db, handler, row, output_dir):
"""One image's verdict dict."""
path = row["image_path"]
@@ -152,6 +160,9 @@ def judge(db, handler, row, output_dir):
"sources": {},
"target": None,
}
if _manually_linked(db, row["id"]):
# The reviewer named this prompt in Needs review (#536): not ours to undo.
return {**verdict, "verdict": "manual", "target": row["prompt_id"]}
if not os.path.isfile(path):
return {**verdict, "verdict": "missing"}
try:
+48 -24
View File
@@ -43,14 +43,14 @@
},
"gallery-settings.js": {
"lines": 98,
"branches": 94,
"branches": 95,
"functions": 98,
"statements": 98
},
"gallery.js": {
"lines": 54,
"branches": 72,
"functions": 57,
"functions": 58,
"statements": 54
},
"html-escape.js": {
@@ -107,6 +107,12 @@
"functions": 98,
"statements": 98
},
"workspace-entry.js": {
"lines": 80,
"branches": 80,
"functions": 80,
"statements": 80
},
"save-to-library.js": {
"lines": 98,
"branches": 98,
@@ -210,10 +216,10 @@
"statements": 98
},
"workspace/file-inspector.mjs": {
"lines": 96,
"lines": 97,
"branches": 93,
"functions": 93,
"statements": 96
"statements": 97
},
"workspace/heading.mjs": {
"lines": 98,
@@ -282,16 +288,10 @@
"statements": 98
},
"workspace/images-delete.mjs": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
},
"workspace/images-return.mjs": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
"lines": 95,
"branches": 81,
"functions": 92,
"statements": 95
},
"workspace/images-export.mjs": {
"lines": 98,
@@ -311,6 +311,12 @@
"functions": 98,
"statements": 98
},
"workspace/images-return.mjs": {
"lines": 93,
"branches": 86,
"functions": 98,
"statements": 93
},
"workspace/images-selection.mjs": {
"lines": 98,
"branches": 93,
@@ -349,7 +355,7 @@
},
"workspace/images.mjs": {
"lines": 97,
"branches": 89,
"branches": 90,
"functions": 98,
"statements": 97
},
@@ -362,7 +368,7 @@
"workspace/inspect.mjs": {
"lines": 98,
"branches": 92,
"functions": 93,
"functions": 94,
"statements": 98
},
"workspace/inspection-state.mjs": {
@@ -373,7 +379,7 @@
},
"workspace/inspector-collections.mjs": {
"lines": 98,
"branches": 92,
"branches": 93,
"functions": 98,
"statements": 98
},
@@ -391,7 +397,7 @@
},
"workspace/library-editor.mjs": {
"lines": 98,
"branches": 93,
"branches": 94,
"functions": 92,
"statements": 98
},
@@ -401,6 +407,12 @@
"functions": 98,
"statements": 98
},
"workspace/older-images-notice.mjs": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
},
"workspace/library-location.mjs": {
"lines": 94,
"branches": 91,
@@ -492,10 +504,10 @@
"statements": 98
},
"workspace/more-menu.mjs": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
"lines": 98,
"branches": 86,
"functions": 98,
"statements": 98
},
"workspace/plural.mjs": {
"lines": 98,
@@ -539,9 +551,15 @@
"functions": 80,
"statements": 95
},
"workspace/review-link.mjs": {
"lines": 95,
"branches": 70,
"functions": 91,
"statements": 95
},
"workspace/review-state.mjs": {
"lines": 96,
"branches": 84,
"branches": 85,
"functions": 98,
"statements": 96
},
@@ -607,7 +625,7 @@
},
"workspace/system-sources.mjs": {
"lines": 98,
"branches": 88,
"branches": 89,
"functions": 95,
"statements": 98
},
@@ -700,5 +718,11 @@
"branches": 95,
"functions": 98,
"statements": 97
},
"workspace/sticky-chrome.mjs": {
"lines": 80,
"branches": 60,
"functions": 80,
"statements": 80
}
}
+8 -1
View File
@@ -37,6 +37,7 @@ from pathlib import Path
from typing import Dict, Any
from .logging_config import get_logger
from .media_folders import in_skipped_folder
try:
from ..database.workflow_documents import DocumentReader, resolve_image_row
@@ -298,7 +299,13 @@ class GalleryDiagnostics:
try:
image_files = []
for ext in [".png", ".jpg", ".jpeg", ".webp"]:
image_files.extend(Path(abs_path).rglob(f"*{ext}"))
image_files.extend(
found
for found in Path(abs_path).rglob(f"*{ext}")
if not in_skipped_folder(
found.relative_to(abs_path).parts[:-1]
)
)
self.logger.info(f" [IMG] Images found: {len(image_files)}")
except Exception as e:
self.logger.error(f" [FAIL] Error scanning: {e}")
+80 -82
View File
@@ -1,15 +1,16 @@
"""Show one gallery file in this computer's file manager (TESTING.md D38).
The route that calls this is local-only (``is_local_request``) and
single-flight (``RevealGate``), and hands in a path it has already resolved
inside a gallery root, which is re-checked just before launching. Windows
The route that calls this takes a same-origin browser request from anywhere,
directly or through a proxy (``is_same_origin_request``), and the folder opens on
the computer running ComfyUI (maintainer decision, #533). It is single-flight
(``RevealGate``) and hands in a path it has already resolved inside a gallery
root, which is re-checked just before launching. Windows
goes through the shell API (no command line exists to parse); macOS and Linux
openers run as an argument list with an absolute executable from a fixed
system PATH, no shell, no inherited streams and a minimal environment, so
neither a file name nor ``PATH`` can change what runs or what it parses.
"""
import ipaddress
import os
import posixpath
import shutil
@@ -26,23 +27,6 @@ from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlsplit
# A local reverse proxy makes every client look like loopback, so a request
# that carries any forwarding-style header counts as remote: these names, and
# every X-Forwarded-*, X-Original-* and *-Client-IP header.
FORWARDING_HEADERS = frozenset(
(
"forwarded",
"via",
"x-real-ip",
"cf-connecting-ip",
"true-client-ip",
"x-client-ip",
"x-cluster-client-ip",
"fastly-client-ip",
)
)
FORWARDING_PREFIXES = ("x-forwarded-", "x-original-")
FORWARDING_SUFFIXES = ("-client-ip",)
DEFAULT_PORTS = {"http": 80, "https": 443}
# One reveal at a time per process, then a pause before the next.
REVEAL_COOLDOWN = 1.0
@@ -51,6 +35,9 @@ REVEAL_COOLDOWN = 1.0
DBUS_REPLY_TIMEOUT_MS = 2000
# The dbus-send process itself gets a little longer before it is killed.
DBUS_WAIT = 3.0
# An opener that fails says so at once; one still running after this long has
# started a file manager (it may stay in the foreground).
OPENER_WAIT = 1.0
FILE_MANAGER_DEST = "org.freedesktop.FileManager1"
# Openers are looked up here only, never on ComfyUI's (venv) PATH, and the
# children get this as their PATH.
@@ -72,16 +59,27 @@ CHILD_ENV_NAMES = (
"XDG_DATA_DIRS",
"XDG_CONFIG_DIRS",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
# The X11 cookie: a non-default path under GDM or SDDM.
"XAUTHORITY",
"DBUS_SESSION_BUS_ADDRESS",
"LANG",
"LC_ALL",
# What xdg-open reads to choose an opener (kde-open6 on Plasma 6).
"KDE_FULL_SESSION",
"KDE_SESSION_VERSION",
"GNOME_DESKTOP_SESSION_ID",
"MATE_DESKTOP_SESSION_ID",
"LXQT_SESSION_CONFIG",
)
class OpenerUnavailable(Exception):
"""No file manager can be started on this computer."""
"""No file manager can be started on the computer running ComfyUI."""
class OpenerFailed(Exception):
"""The file manager's opener ran and reported a failure."""
class RevealTargetChanged(Exception):
@@ -104,29 +102,6 @@ def reveal_executor() -> ThreadPoolExecutor:
return _executor
def _loopback_address(value: str | None) -> bool:
if not value:
return False
try:
address = ipaddress.ip_address(value)
except ValueError:
return False
mapped = getattr(address, "ipv4_mapped", None)
return (mapped or address).is_loopback
def _loopback_host(host: str) -> bool:
"""True for a Host name naming this computer.
Rejects every other name, so a DNS-rebinding page whose name now resolves
to 127.0.0.1 still reads as remote.
"""
host = host.rstrip(".").lower()
if host == "localhost" or host.endswith(".localhost"):
return True
return _loopback_address(host.strip("[]"))
def _authority(authority: str) -> tuple[str, int | None] | None:
"""``(host, port)`` of a Host value, or None when it does not parse."""
try:
@@ -137,30 +112,29 @@ def _authority(authority: str) -> tuple[str, int | None] | None:
return (host, port) if host else None
def _forwarded(headers: Mapping) -> bool:
for name in headers.keys():
name = name.lower()
if (
name in FORWARDING_HEADERS
or name.startswith(FORWARDING_PREFIXES)
or name.endswith(FORWARDING_SUFFIXES)
):
return True
return False
def _first_value(headers: Mapping, name: str) -> str | None:
"""The client-side entry of a comma-separated forwarding header, if any."""
value = headers.get(name)
return (value.split(",")[0].strip() or None) if value else None
def _same_origin(origin: str, scheme: str, host: tuple[str, int | None]) -> bool:
"""True when *origin* is exactly this request's scheme://host:port."""
default = DEFAULT_PORTS.get(scheme)
if default is None:
return False
def _same_origin(
origin: str, proto: str | None, scheme: str, host: tuple[str, int | None]
) -> bool:
"""True when *origin* names the page at *host*.
The port is compared after filling in the Origin scheme's default. The
scheme must be the one the proxy reports, else this socket's; an https page
on an http socket with no report passes, as a proxy ended TLS before us.
"""
try:
parts = urlsplit(origin)
origin_host, origin_port = parts.hostname, parts.port
except ValueError:
return False
default = DEFAULT_PORTS.get(parts.scheme)
if (
parts.scheme != scheme
default is None
or not origin_host
or parts.username is not None
or parts.password is not None
@@ -169,33 +143,42 @@ def _same_origin(origin: str, scheme: str, host: tuple[str, int | None]) -> bool
or parts.fragment
):
return False
if proto is not None:
scheme_matches = parts.scheme == proto
else:
scheme_matches = parts.scheme == scheme or (
scheme == "http" and parts.scheme == "https"
)
host_name, host_port = host
return (origin_host.rstrip("."), origin_port or default) == (
return scheme_matches and (origin_host.rstrip("."), origin_port or default) == (
host_name.rstrip("."),
host_port or default,
)
def is_local_request(remote: str | None, headers: Mapping, scheme="http") -> bool:
"""True only for a same-origin browser request from this computer.
def is_same_origin_request(headers: Mapping, scheme="http") -> bool:
"""True only for a request whose page is this server, as the browser sees it.
That is: no forwarding-style header, a loopback client, a loopback name
in Host, an ``Origin`` that is exactly this request's scheme://host:port
(browsers send it on every fetch POST, so a missing one is refused), and
``Sec-Fetch-Site: same-origin`` whenever that header is sent.
Compares ``Origin`` (browsers send it on every fetch POST, so a missing one
is refused) with ``X-Forwarded-Host`` when present, else ``Host``, and with
``X-Forwarded-Proto`` when present, else *scheme*. ``Sec-Fetch-Site`` must
be ``same-origin`` whenever it is sent. That stops a plain cross-site POST,
whose browser sets ``Origin`` and ``Sec-Fetch-Site`` itself. It does not stop
a page whose name was rebound to this server, as it trusts ``Host`` and
``X-Forwarded-Host`` (TESTING.md D38): what limits that is the path check.
"""
if _forwarded(headers):
return False
if not _loopback_address(remote):
return False
host = _authority(headers.get("Host", ""))
if host is None or not _loopback_host(host[0]):
return False
origin = headers.get("Origin")
if not origin or not _same_origin(origin, scheme, host):
if not origin:
return False
site = headers.get("Sec-Fetch-Site")
return site is None or site == "same-origin"
if site is not None and site != "same-origin":
return False
authority = _first_value(headers, "X-Forwarded-Host") or headers.get("Host", "")
host = _authority(authority)
if host is None:
return False
proto = _first_value(headers, "X-Forwarded-Proto")
return _same_origin(origin, proto.lower() if proto else None, scheme, host)
class RevealGate:
@@ -393,6 +376,19 @@ def _launch(argv: list[str]) -> subprocess.Popen:
return subprocess.Popen(argv, **options) # nosec B603
def _launch_opener(argv: list[str]) -> None:
"""Start an opener and report its failure (OpenerFailed), never waiting on
a file manager it stays attached to: that one is left to the reaper."""
process = _launch(argv)
try:
status = process.wait(timeout=OPENER_WAIT)
except subprocess.TimeoutExpired:
_reap(process)
return
if status != 0:
raise OpenerFailed(f"exit status {status}")
def _show_items(path: Path) -> bool:
"""Ask the freedesktop FileManager1 service to select *path*; True only
when the service replied (``--print-reply`` makes the exit status real)."""
@@ -427,8 +423,9 @@ def reveal_in_file_manager(path: Path, root: Path) -> None:
"""Open the file manager at *path*, a regular file inside *root*.
Re-checks that containment first (RevealTargetChanged). Raises
OpenerUnavailable when no opener exists and OSError when the one found
cannot start. Never waits on the file manager itself.
OpenerUnavailable when no opener exists or Linux has no graphical session,
OSError when the one found cannot start and OpenerFailed when it reports a
failure. Never waits on the file manager itself.
"""
path = Path(path)
if not path.is_absolute():
@@ -441,11 +438,12 @@ def reveal_in_file_manager(path: Path, root: Path) -> None:
opener = _macos_open()
if opener is None:
raise OpenerUnavailable()
_reap(_launch([opener, "-R", str(path)]))
_launch_opener([opener, "-R", str(path)])
return
if _show_items(path):
return
opener = _executable("xdg-open")
if opener is None:
has_display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
if opener is None or not has_display:
raise OpenerUnavailable()
_reap(_launch([opener, str(path.parent)]))
_launch_opener([opener, str(path.parent)])
+36 -2
View File
@@ -6,16 +6,22 @@ inside an output root (``D:\\$RECYCLE.BIN`` when ``D:\\`` is a root,
the user threw away: they are not outputs, so they are never listed, scanned,
linked, tagged or previewed. Every walker asks these predicates instead of
testing names itself; only folders *below* a root are tested.
``in_trash_below_roots`` is the check for a path someone names (#543): it
resolves the path first, so a link or another spelling cannot get past it.
"""
import os
TRASH_FOLDER_NAMES = frozenset({".trash", ".trashes", "$recycle.bin"})
PREVIEW_FOLDER = "thumbnails"
def is_trash_folder(name: str) -> bool:
"""A trash folder name, in any case: ``.Trash``, ``.Trash-<uid>``,
``.Trashes`` or ``$RECYCLE.BIN``."""
folded = name.casefold()
``.Trashes`` or ``$RECYCLE.BIN``. Windows ignores a name's trailing dots
and spaces, so ``$RECYCLE.BIN.`` is that folder; they are ignored here too."""
folded = name.rstrip(" .").casefold()
if folded in TRASH_FOLDER_NAMES:
return True
return folded.startswith(".trash-") and folded[7:].isdigit()
@@ -26,6 +32,34 @@ def is_skipped_folder(name: str) -> bool:
return name == PREVIEW_FOLDER or is_trash_folder(name)
def in_trash_folder(relative: str) -> bool:
"""Whether a root-relative file path lies in a trash folder (#543).
Takes either separator. Previews of it (``thumbnails/.Trash-1000/...``) count.
"""
folders = relative.replace("\\", "/").split("/")[:-1]
return any(is_trash_folder(part) for part in folders)
def in_trash_below_roots(path, roots) -> bool:
"""Whether *path*, resolved, is in a trash folder below every root holding it.
A source named like a trash folder is still a source, so a file it holds
with no trash folder below it is not in one. Paths compare resolved and
normcased. A path no root visibly holds tests its whole path: a spelling
difference must not fail open.
"""
real = os.path.normcase(os.path.realpath(path))
below = []
for root in roots:
real_root = os.path.normcase(os.path.realpath(root))
if real.startswith(os.path.join(real_root, "")):
below.append(os.path.relpath(real, real_root))
if not below:
return in_trash_folder(real)
return all(in_trash_folder(relative) for relative in below)
def in_skipped_folder(parts) -> bool:
"""Whether any of the folder names *parts* is skipped."""
return any(is_skipped_folder(part) for part in parts)
+18 -4
View File
@@ -6,7 +6,10 @@ Two rules hold for every backend:
filesystem. Nothing is ever copied, unlinked or overwritten: a file that
cannot be moved recoverably stays where it is, and ``TrashRefused`` says why.
- **Never into a root.** A trash folder inside an output root would be listed
as images, so it is refused (``trash-in-root``).
as images, so it is refused (``trash-in-root``). The one exception (#543): for
an output root that is exactly a mount root, that mount's own ``.Trash-$uid``
and ``.Trash/$uid``, which every walker and media route ignores
(``utils.media_folders``).
Linux and BSD use :class:`FreedesktopBackend`, the freedesktop.org Trash
specification 1.0: the home trash (``$XDG_DATA_HOME/Trash``) for a file on the
@@ -184,10 +187,21 @@ def _canonical(path: str) -> str:
return os.path.normcase(os.path.realpath(path))
def _check_roots(trash_dir: str, roots) -> None:
def _check_roots(trash_dir: str, roots, top: str | None = None) -> None:
"""Refuse a trash that a root would list, or that holds a root.
*top* is the mount a per-drive *trash_dir* was built from. A root that is
exactly that mount may hold it, unless a link makes the trash really live
somewhere else (#543). Any other root holding it refuses, also beside one.
"""
trash_key = _canonical(trash_dir)
top_key = None if top is None else _canonical(top)
unlinked = trash_key == os.path.normcase(trash_dir)
for root in roots:
if _inside(trash_key, _canonical(str(root))):
root_key = _canonical(str(root))
if _inside(root_key, trash_key):
raise TrashRefused(TRASH_IN_ROOT)
if _inside(trash_key, root_key) and not (unlinked and root_key == top_key):
raise TrashRefused(TRASH_IN_ROOT)
@@ -267,7 +281,7 @@ class FreedesktopBackend:
candidates.append(os.path.join(shared, str(self._uid)))
candidates.append(os.path.join(top, f".Trash-{self._uid}"))
for trash_dir in candidates:
_check_roots(trash_dir, roots)
_check_roots(trash_dir, roots, top)
if self._usable(trash_dir, create=create):
label = f"{os.path.relpath(trash_dir, top)} on its drive"
return trash_dir, top, label
+10 -9
View File
@@ -7,6 +7,7 @@ const { installFocusedNode, installSplitNode, installPairNode } = await import(a
const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js'));
const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
const { normalizeSaveWidget } = await import(api.fileURL('/prompt_manager/js/save-to-library.js'));
const { WORKSPACE_ENTRY_ROUTE } = await import(api.fileURL('/prompt_manager/js/workspace-entry.js'));
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
// ============================================================================
@@ -109,7 +110,7 @@ function addPMTopBarButton() {
// Create PM button
const pmButton = $el("button", {
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
title: "PromptManager - Open Admin Dashboard",
title: "PromptManager - Open workspace",
style: {
display: "flex",
alignItems: "center",
@@ -125,7 +126,7 @@ function addPMTopBarButton() {
transition: "background 0.2s",
},
onclick: () => {
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_blank");
},
onmouseenter: (e) => { e.target.style.background = "#0d9488"; },
onmouseleave: (e) => { e.target.style.background = "#14b8a6"; },
@@ -161,7 +162,7 @@ function addPMTopBarButtonLegacy() {
const btn = document.createElement("button");
btn.id = "pm-toolbar-btn";
btn.innerText = "PM";
btn.title = "Open PromptManager Admin Dashboard";
btn.title = "Open PromptManager workspace";
// Teal button styling
btn.style.cssText = `
@@ -181,7 +182,7 @@ function addPMTopBarButtonLegacy() {
btn.onmouseleave = () => btn.style.background = "#14b8a6";
btn.onclick = () => {
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_blank");
};
topBar.appendChild(btn);
@@ -1184,9 +1185,7 @@ app.registerExtension({
// Method to open web interface
nodeType.prototype.openWebInterface = function () {
try {
// Open the web interface served via our API endpoint
const currentOrigin = window.location.origin;
const webUrl = `${currentOrigin}/prompt_manager/web`;
const webUrl = api.fileURL(WORKSPACE_ENTRY_ROUTE);
if (this.properties.webuiDisplayMode === "newtab") {
// Open in new tab without window options
@@ -1198,7 +1197,8 @@ app.registerExtension({
"success",
);
} else {
window.location.href = webUrl;
// Never navigate the canvas away: it may hold unsaved work.
this.showNotification("Allow popups to open Web UI, then try again.", "error");
}
} else {
// Default popup mode with specific dimensions
@@ -1211,7 +1211,8 @@ app.registerExtension({
if (newWindow) {
this.showNotification("Web interface opened in popup", "success");
} else {
window.location.href = webUrl;
// Never navigate the canvas away: it may hold unsaved work.
this.showNotification("Allow popups to open Web UI, then try again.", "error");
}
}
} catch (error) {
+5 -5
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Redirecting to Admin</title>
<title>PromptManager - Opening the workspace</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -49,13 +49,13 @@
<div class="container">
<div class="spinner"></div>
<h1>🔍 PromptManager</h1>
<p>Redirecting to Admin Dashboard...</p>
<p><small>Loading the full-featured prompt management interface</small></p>
<p>Opening the workspace...</p>
<noscript><p><a href="workspace#/library">Open the workspace</a></p></noscript>
</div>
<script>
// Redirect to admin interface immediately
window.location.href = window.location.pathname.replace(/\/(?:web|index\.html)\/?$/, '/admin');
// Replace, so Back doesn't return here and bounce. Same route as web/js/workspace-entry.js (#525).
window.location.replace(window.location.pathname.replace(/\/(?:web|index\.html)\/?$/, '/workspace#/library'));
</script>
</body>
</html>
+2 -1
View File
@@ -1,5 +1,6 @@
import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js';
import {createPromptLibrary} from './prompt-library.js';
import {WORKSPACE_ENTRY_ROUTE} from './workspace-entry.js';
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js';
import {SAVE_WIDGET, normalizeSaveWidget, placeSaveWidgetLast, saveWidget, savesToLibrary, setSaveToLibrary} from './save-to-library.js';
@@ -253,7 +254,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
}
});
scope.listen(web,'click',() => {
const url = api.fileURL('/prompt_manager/web');
const url = api.fileURL(WORKSPACE_ENTRY_ROUTE);
const opened = webDisplayMode === 'newtab' ? window.open(url,'_blank') : window.open(url,'_blank','width=1200,height=800,scrollbars=yes,resizable=yes');
footer.setAttribute('role',opened ? 'status' : 'alert');
footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.';
+2
View File
@@ -0,0 +1,2 @@
// Where the canvas's PM and Web UI buttons open PromptManager (#525). web/index.html redirects to the same route.
export const WORKSPACE_ENTRY_ROUTE = '/prompt_manager/workspace#/library';
+4 -4
View File
@@ -26,9 +26,9 @@ const CATEGORY_PREFIX = {image: 'Image category: ', positive: 'Positive prompt c
const CATEGORY_ORIGIN = {image: 'this image', positive: 'prompt', pair: 'recipe'};
const REVEAL_URL = '/prompt_manager/images/reveal';
// D38 Show in folder: the route's answers, in the maintainer's words.
const REVEAL_OPENED = 'Opened the folder on this computer.';
const REVEAL_NOT_LOCAL = 'Show in folder works only in a browser on the computer running ComfyUI.';
const REVEAL_UNAVAILABLE = 'No file manager is available on this computer.';
const REVEAL_OPENED = 'Opened the folder on the computer running ComfyUI.';
const REVEAL_REFUSED = "The folder was not opened: this page's address does not match the server's.";
const REVEAL_UNAVAILABLE = 'No file manager is available on the computer running ComfyUI.';
const REVEAL_BUSY = 'The folder is already opening.';
const REVEAL_FAILED = 'The folder could not be opened. Try again.';
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
@@ -109,7 +109,7 @@ export function revealTarget(identity) {
/** The toast for a reveal outcome: null is success, otherwise the request's error (D38). */
export function revealOutcome(error) {
if (error === null) return REVEAL_OPENED;
if (error?.status === 403 && error.code === 'not-local') return REVEAL_NOT_LOCAL;
if (error?.status === 403 && error.code === 'not-same-origin') return REVEAL_REFUSED;
if (error?.status === 501) return REVEAL_UNAVAILABLE;
if (error?.status === 429 && error.code === 'busy') return REVEAL_BUSY;
return REVEAL_FAILED;
+3 -1
View File
@@ -24,7 +24,9 @@ const paths = Object.freeze({
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5",
// #450 (D156): the viewer's Full screen / Centered switch.
"maximize": "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5",
"minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"
"minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5",
// #532 (D170): the viewer's keyboard shortcuts.
"command": "M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"
});
/** Create an approved decorative outline icon without injecting markup. */
+2 -2
View File
@@ -35,7 +35,7 @@ export function collectionTargets(images, { allMatching = false, total = images.
* the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing.
*/
export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length,
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null }) {
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null, onAdded = null }) {
const { targets, refusal } = collectionTargets(images, { allMatching, total });
if (refusal) { notify(refusal); return; }
openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION,
@@ -45,7 +45,7 @@ export function openAddToCollection({ scope, collections, images, trigger = null
if (name === null) { notify(ADD_FAILED_TOAST); return; }
try {
const result = await collections.add({ name }, targets);
if (!scope.signal.aborted) notify(addedMessage(result));
if (!scope.signal.aborted) { notify(addedMessage(result)); onAdded?.(); }
} catch (error) {
if (scope.signal.aborted) return;
notify(writeFailure(error, ADD_FAILED_TOAST));
+6 -3
View File
@@ -1,3 +1,4 @@
import { keepingPlace, publishBarHeight } from './sticky-chrome.mjs';
import { createButton } from '../shared/toolkit/core.js';
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
@@ -51,7 +52,7 @@ function el(tag, text, className) {
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null, removedView = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onCollectionAdded = null, onDeleted = null, removedView = null,
onReturn = null }) {
let checked = initial;
let exporting = false, categorizing = false;
@@ -59,6 +60,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
let anchor = null, allMatching = false, touchMode = false, lastPointer = 'mouse', shiftBox = null;
const element = el('div');
element.id = 'bulkbar';
publishBarHeight(scope, element);
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const selectMatching = createButton('');
scope.listen(selectMatching, 'click', () => {
@@ -87,7 +89,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
if (collections) {
const addToCollection = createButton('Add to collection');
scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(),
trigger: addToCollection, ...collectionSelection(), onSourceChanged }));
trigger: addToCollection, ...collectionSelection(), onSourceChanged, onAdded: onCollectionAdded }));
bulk.append(addToCollection);
}
if (collections && collection) {
@@ -124,7 +126,8 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
}
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
function render() { keepingPlace(element, paintBar); }
function paintBar() {
if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; }
paintMode();
const listing = getListing();
+32 -7
View File
@@ -5,6 +5,7 @@ import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './imag
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
import { scrollToPageTop } from './sticky-chrome.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
@@ -301,6 +302,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
collection.append(continuation);
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
let focusAfterLoad = null;
let panelHold = false;
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
const details = el('aside', undefined, 'detail-panel');
if (!embedded) details.id = 'image-inspector';
@@ -310,11 +312,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
onDeleted:deleted,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); scrollToPageTop(); }, activity,
// #531: a finished bulk action shows its result from the top. Tagging and collections keep the selection (D85, D138).
onTagged:keys => { refreshTagged(keys); bulkRefreshed(); },
onDeleted:() => { deleted(); scrollToPageTop(); },
// #498: the removed view's bar holds only Return to library and Clear.
removedView:() => filters.removed,
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger}),
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger, onDone:scrollToPageTop}),
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
@@ -325,7 +329,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter;
},
// #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again.
collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged,
collections, collection:filters.collection || null, onCollectionsChanged:() => { collectionChanged(); scrollToPageTop(); }, onCollectionAdded:bulkRefreshed,
onSourceChanged:refreshInspectionCollection});
if (bar) main.append(bar.element);
main.append(body);
@@ -402,7 +406,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
onRefresh:refreshInspectionCollection})) inspect(image);
onRefresh:refreshInspectionCollection})) {
inspect(image);
bringPanelIntoView();
}
});
function renderVisible() {
@@ -637,7 +644,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
scope.listen(more, 'click', pressLoad);
scope.listen(retry, 'click', () => (state?.error?.code === 'cursor-stale' ? refreshInspectionCollection() : pressLoad()));
/**
* D189: at one column the panel follows the whole list, so Info goes to it. That passes the continuation, so loading
* waits for the reader's next move: a batch appended now would push the panel away.
*/
function bringPanelIntoView() {
if (getComputedStyle(details).position !== 'static') return;
panelHold = true;
details.scrollIntoView({ block: 'start' });
}
const releasePanel = () => { if (panelHold) { panelHold = false; scheduleLoad(); } };
for (const name of ['wheel', 'touchmove', 'keydown']) scope.listen(window, name, releasePanel, { passive: true });
function maybeLoad() {
if (panelHold) return;
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
@@ -702,8 +721,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
else if (!showRemoved.isConnected) heading.actions.prepend(showRemoved);
}
/** #498: Return to library for the checked images, "all N matching" (`filter`) or the inspector's one. */
function returnSome({images = [], filter = null, count = images.length, trigger = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) reset(); }});
function returnSome({images = [], filter = null, count = images.length, trigger = null, onDone = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) { reset(); onDone?.(); } }});
}
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
function deleteOne(target, trigger, after = null) {
@@ -819,6 +838,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
* is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page
* again from its start, whose failure shows the usual Retry (or the deleted-collection page).
*/
/** #531: after a bulk action that keeps the selection: the listing re-read, shown from the top. */
function bulkRefreshed() {
if (scope.signal.aborted) return;
scrollToPageTop();
if (!recovering) stream.refresh().catch(() => { if (!scope.signal.aborted) reset(); });
}
async function collectionChanged() {
if (scope.signal.aborted || !filters.collection || recovering) return;
try {
+49 -26
View File
@@ -1,9 +1,10 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
import { routes, mountRouter, summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS,
JOB_END_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountBackToTop } from './sticky-chrome.mjs';
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
@@ -23,6 +24,7 @@ import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
import { createRestoreReload, onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { mountLibraryBanner } from './library-location.mjs';
import { mountOlderImagesNotice } from './older-images-notice.mjs';
import { detectJxlSupport } from './thumbnail-support.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -34,10 +36,9 @@ function element(tag, text, className) {
return node;
}
function legacyLink(route) {
function cardLink(route) {
const link = element('a', route.action, 'workspace-link');
// A native card (System, Q12) links its workspace hash; a legacy tool's page goes through the mount prefix.
link.href = route.native ? route.href : resolveUrl(route.href);
link.href = route.href;
return link;
}
@@ -83,9 +84,12 @@ function mountWorkspace() {
// row's count and the Activity page, on every route.
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
mountActivityTopbar({ store: activity, scope });
mountBackToTop({ scope, toast: document.getElementById('workspace-toast') });
activity.load();
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
// D166: the one-time upgrade notice, above every page until dismissed; System > Sources shows its own.
const olderImages = mountOlderImagesNotice(document.getElementById('older-images-notice'), { api, activity, scope, main });
const sidebar = document.querySelector('aside.sidebar');
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`, a collection's `collection/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}`
@@ -149,9 +153,13 @@ function mountWorkspace() {
countRequests.get(id)?.abort();
const request = new AbortController();
countRequests.set(id, request);
// A failed count reads as loading again while it is retried; a known one stays until replaced.
if (knownCounts.get(id) === null) { knownCounts.delete(id); notifyCounts(); }
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (_, count) => {
if (!request.signal.aborted) showCount(id, count);
if (request.signal.aborted) return;
countRequests.delete(id);
showCount(id, count);
}, [id]);
}
}
@@ -159,7 +167,15 @@ function mountWorkspace() {
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
knownCounts.set(id, count);
notifyCounts();
}
/** #547: Overview shows these same counts, so a card and its badge cannot differ, and no read runs twice. */
const knownCounts = new Map();
const countListeners = new Set();
function notifyCounts() { for (const listener of countListeners) listener(); }
/** Re-read the given counts unless a read is already in flight. */
function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); }
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
function setCount(id, count) {
countRequests.get(id)?.abort();
@@ -260,6 +276,7 @@ function mountWorkspace() {
function renderNow(route) {
leaveGuard = null;
olderImages.showFor(route);
// A shared page dialog never stays open over the next route.
closeWorkspaceDialog();
if (route.id === 'system' && system && currentKey === 'system') {
@@ -485,43 +502,47 @@ function mountWorkspace() {
stats.setAttribute('aria-label', 'Library statistics');
main.append(stats);
const tools = element('section', undefined, 'surface');
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
tools.append(element('h2', 'Continue creating'), element('p', 'Pick up where you left off in your images, prompts and tools.'));
const cards = element('div', undefined, 'tool-grid');
for (const target of routes.filter(item => item.href)) {
const card = element('article', undefined, 'tool-card');
card.append(element('h3', target.label), element('p', target.description, 'small'), legacyLink(target));
card.append(element('h3', target.label), element('p', target.description, 'small'), cardLink(target));
cards.append(card);
}
tools.append(cards);
main.append(tools);
async function loadStats() {
const status = element('p', 'Loading library statistics…');
status.setAttribute('role', 'status');
stats.replaceChildren(status);
try {
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
if (view.signal.aborted) return;
const values = summarizeStats(result.stats);
function showStats() {
const { cards, pending, failed, empty } = summarizeOverviewCounts(knownCounts);
const parts = [];
if (cards.some(([, count]) => count !== null)) {
const list = element('dl', undefined, 'stat-grid');
for (const [label, count] of values) {
for (const [label, count] of cards) {
const stat = element('div', undefined, 'stat');
stat.append(element('dt', label), element('dd', count.toLocaleString()));
stat.append(element('dt', label), element('dd', count === null ? '–' : count.toLocaleString()));
list.append(stat);
}
stats.replaceChildren(list);
if (values.every(([, count]) => count === 0)) {
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
}
} catch (error) {
if (view.signal.aborted) return;
parts.push(list);
}
if (pending) {
const status = element('p', 'Loading library statistics…');
status.setAttribute('role', 'status');
parts.push(status);
} else if (failed) {
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', loadStats);
stats.replaceChildren(alert, retry);
view.listen(retry, 'click', () => refreshCounts(OVERVIEW_COUNT_ROUTES.filter(id => knownCounts.get(id) === null)));
parts.push(alert, retry);
} else if (empty) {
parts.push(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
}
stats.replaceChildren(...parts);
}
loadStats();
countListeners.add(showStats);
view.cleanup(() => countListeners.delete(showStats));
showStats();
// A visit reads them again, as a generation since the last read changes the sidebar's badges too.
freshenCounts(OVERVIEW_COUNT_ROUTES);
}
scope.cleanup(mountRouter(window, render));
scope.listen(window, 'pagehide', () => scope.dispose());
@@ -529,6 +550,8 @@ function mountWorkspace() {
}
mountWorkspace();
// #525: web/workspace.html shows its legacy-page fallback at `load` unless the shell got this far.
document.documentElement.dataset.workspaceBooted = 'true';
window.addEventListener('pageshow', event => {
if (event.persisted) mountWorkspace();
});
+7 -1
View File
@@ -1,3 +1,4 @@
import { keepingPlace, publishBarHeight } from './sticky-chrome.mjs';
import { createButton } from '../shared/toolkit/core.js';
import { selectionLabel, toggleSelection } from './images-state.mjs';
import { applyKeys, rangeKeys } from './images-multiselect.mjs';
@@ -129,10 +130,11 @@ export function categoryProblem(value) {
* to `focusFallback` when it was on the bar the reload hides.
*/
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
onReload, getShelf = () => 'active', nameOf = () => null }) {
onReload, getShelf = () => 'active', nameOf = () => null, onPlaceKept = () => {} }) {
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
const element = el('div');
element.id = 'library-bulkbar';
publishBarHeight(scope, element);
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const addTags = createButton('Add tags');
const setCategory = createButton('Set category');
@@ -164,6 +166,10 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
const ids = () => getItems().map(item => item.id);
function render() {
const shift = keepingPlace(element, paintBar);
if (shift) onPlaceKept(shift);
}
function paintBar() {
if (!checked.size) { bulk.remove(); return; }
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
if (shelf !== shownShelf) {
+4 -2
View File
@@ -16,6 +16,7 @@ import {mountToolbar} from './toolbar.mjs';
import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {scrollToPageTop} from './sticky-chrome.mjs';
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs';
import {createMoreMenu} from './more-menu.mjs';
@@ -190,10 +191,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
let categoryNames=[];
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
// #531: the bar's compensating scroll is not the reader moving, so a pending anchor keeps its offset.
const selection=mountLibrarySelection({scope,api,grid,onPlaceKept:shift=>{observedScrollY+=shift;},getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},getShelf:()=>filters.shelf,
nameOf:id=>{const item=state?.items.find(entry=>entry.id===id);return item?displayTitle(item):null;},
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();scrollToPageTop();}});
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
+64 -9
View File
@@ -13,6 +13,13 @@ export function readViewerPreferences(getStorage = () => window.localStorage, no
const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values;
return {videoAutoplay,videoMute,videoLoop};
}
// #532: where ← and → stay with what has focus (a video's seek, text carets, a disclosure, scrolling text).
const KEEPS_ARROWS = 'video,input,textarea,select,pre,summary,[contenteditable]';
const SHORTCUTS = [
['Move',[['←','Previous file'],['→','Next file'],['Space','Play or pause the slideshow']]],
['Image',[['+','Zoom in'],['-','Zoom out'],['0','Fit'],['R','Rotate'],['H','Flip']]],
['View',[['F','Full screen or centered'],['I','Show or hide details'],['?','These shortcuts'],['Esc','Close the viewer']]],
['File',[['D','Download the original'],['Delete','Delete…, which asks first']]]];
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -60,7 +67,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
// #450 (D156): the view switch before ✕ names the view it switches to; the stored default picks the first view.
let fullscreen = displayPreferences()?.viewerOpenMode === 'fullscreen';
const mode = createButton('','icon-btn');mode.id = 'viewer-mode';
header.append(heading,mode,close);
// #532 (D170): ⌘ before the view switch lists the viewer's shortcuts.
const keys = createButton('','icon-btn');keys.id = 'viewer-keys';keys.title = 'Keyboard shortcuts';
keys.setAttribute('aria-label','Keyboard shortcuts');keys.append(createWorkspaceIcon('command'));
header.append(heading,keys,mode,close);
const body = element('div','viewer-layout'), left = element('div','viewer-left');
const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0;
stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview');
@@ -83,8 +93,9 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
controls.append(download);
// #480: Delete… (the Images viewer only) pauses the media, then opens the delete review for this file.
let remove = null;
if (onDelete) {
const remove = createButton('Delete…','danger');
remove = createButton('Delete…','danger');
local.listen(remove,'click',() => {
session.stop();
for (const video of dialog.querySelectorAll('video')) video.pause();
@@ -123,6 +134,29 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
}
local.listen(detailsToggle,'click',() => {details.hidden = !details.hidden;paintToggle();});
local.listen(mode,'click',() => {fullscreen = !fullscreen;paintMode();syncToggle();});
/** A modal inside the viewer: Esc closes only the top dialog, `close` and `cancel` do not bubble, focus returns to ⌘. */
function openShortcuts() {
session.stop();
const overlay = element('dialog');overlay.id = 'viewer-keys-dialog';overlay.setAttribute('aria-labelledby','viewer-keys-title');
const top = element('div','dialog-header'), name = element('h2',undefined,'Keyboard shortcuts');name.id = 'viewer-keys-title';
const dismiss = createButton('×');dismiss.setAttribute('aria-label','Close keyboard shortcuts');dismiss.autofocus = true;
top.append(name,dismiss);
const groups = element('div','viewer-keys-groups');
for (const [group,rows] of SHORTCUTS) {
const section = element('section'), list = element('dl');
for (const [key,action] of rows) {
if (key === 'Delete' && !remove) continue;
const row = element('div'), term = element('dt'), kbd = element('kbd',undefined,key);
term.append(kbd);row.append(term,element('dd',undefined,action));list.append(row);
}
section.append(element('h3','small muted',group),list);groups.append(section);
}
overlay.append(top,groups,element('p','muted small','A video player or text with focus keeps its keys. A key does nothing when its button is unavailable for this file.'));
local.listen(dismiss,'click',() => overlay.close());
local.listen(overlay,'close',() => overlay.remove());
dialog.append(overlay);overlay.showModal();
}
local.listen(keys,'click',openShortcuts);
paintMode();syncToggle();
body.append(left,details);dialog.append(header,body);container.append(dialog);
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
@@ -154,12 +188,30 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
local.listen(document,'visibilitychange',() => {if (document.hidden) session.stop();});
local.listen(window,'pagehide',() => closeViewer(false));
local.listen(retry,'click',() => {if (!inspectionState.refreshRequired) rebuild(entry);});
local.listen(dialog,'keydown',event => {
if (!['ArrowLeft','ArrowRight'].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
(!stage.contains(event.target) && !controls.contains(event.target)) ||
event.target.closest('video,input,textarea,select,pre,summary,[contenteditable]') ||
!window.getSelection()?.isCollapsed || [...document.querySelectorAll('dialog[open]')].at(-1) !== dialog) return;
event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1);
// #532: on the document, as the legacy gallery does: focus starts on ✕ and can fall to the page (a click on
// header text, a focused video replaced by navigating), where a key never reaches the dialog.
// A key clicks the toolbar's own control, whose handler checks `aria-disabled`, so the two cannot disagree; a
// control that is missing or hidden has no client rects. Only zoom repeats, so holding D downloads once.
const [zoomOut,zoomIn,fit,rotate,flip] = transforms;
const hotkeys = new Map([['+',zoomIn],['=',zoomIn],['-',zoomOut],['0',fit],['r',rotate],['h',flip],[' ',slideshow],
['f',mode],['i',detailsToggle],['d',download],['Delete',remove]]);
const repeats = new Set(['+','=','-']);
const keysAreOurs = ({target,altKey,ctrlKey,metaKey,isComposing}) => !altKey && !ctrlKey && !metaKey && !isComposing &&
(dialog.contains(target) || target === document.body || target === document.documentElement) &&
!target.closest(KEEPS_ARROWS) && (!details.contains(target) || target.closest('button,a')) &&
window.getSelection()?.isCollapsed && [...document.querySelectorAll('dialog[open]')].at(-1) === dialog;
local.listen(document,'keydown',event => {
if (!keysAreOurs(event)) return;
const {key,target,repeat,shiftKey} = event;
if (key === 'ArrowLeft' || key === 'ArrowRight') {
if (shiftKey) return;
event.preventDefault();session.navigate(key === 'ArrowLeft' ? -1 : 1);return;
}
if (repeat && !repeats.has(key)) return;
if (key === '?') { event.preventDefault();openShortcuts();return; }
const control = hotkeys.get(key.length === 1 ? key.toLowerCase() : key);
if (!control?.getClientRects().length || (key === ' ' && target.closest('button,a'))) return;
event.preventDefault();control.click();
});
function rebuild(selected) {
mediaScope?.dispose();mediaScope = createScope();viewer = null;
@@ -215,7 +267,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
const release = () => {
if (released) return;
released = true;clearTimeout(fallback);instance.destroy();node.removeAttribute('src');
released = true;clearTimeout(fallback);
// Moving on empties the stage, and destroy() removes the built viewer from its parent.
if (instance.viewer && !instance.viewer.parentNode) document.createElement('div').append(instance.viewer);
instance.destroy();node.removeAttribute('src');
};
const scheduleFit = () => {
if (!current() || fitFrame) return;
+129
View File
@@ -0,0 +1,129 @@
/**
* The one-time upgrade notice "Bring older images into your prompt history" (#527, D166).
*
* Its dismissal is the legacy admin page's own `pm_update_notice_dismissed`, so dismissing it anywhere dismisses it
* everywhere in this browser. The shell shows it above every page; System > Sources keeps the concept's copy in the
* page, so the shell hides on that section and the two never show together. Storage that cannot be written still
* dismisses it for this page.
*/
import { createButton } from '../shared/toolkit/core.js';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
export const NOTICE_KEY = 'pm_update_notice_dismissed';
export const NOTICE_VERSION = '3.2.0';
export const SOURCES_UNREADABLE = 'Could not read the image sources. Try again.';
const SETTINGS_URL = '/prompt_manager/settings';
const defaultStorage = () => window.localStorage;
const changes = new EventTarget();
// The storages this page dismissed the notice in without being able to write it.
const dismissedHere = new WeakSet();
/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */
export function noticeDismissed(getStorage = defaultStorage) {
if (dismissedHere.has(getStorage)) return true;
try {
return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION;
} catch {
return false;
}
}
/** Dismiss the notice everywhere on this page. Returns whether it was saved for the next visit. */
export function dismissNotice(getStorage = defaultStorage) {
let saved = true;
try {
getStorage().setItem(NOTICE_KEY, NOTICE_VERSION);
} catch {
dismissedHere.add(getStorage);
saved = false;
}
changes.dispatchEvent(new Event('dismissed'));
return saved;
}
/** Calls `onDismissed` when the notice is dismissed on this page or in another tab, until `scope` ends. */
export function watchNoticeDismissal({ scope, storage = defaultStorage, onDismissed }) {
changes.addEventListener('dismissed', onDismissed);
scope.cleanup(() => changes.removeEventListener('dismissed', onDismissed));
scope.listen(window, 'storage', event => {
if (event.key === NOTICE_KEY && noticeDismissed(storage)) onDismissed();
});
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The concept's `.notice` with Later and Review scan. */
export function noticeNode({ scope, onLater, onReview }) {
const notice = el('div', undefined, 'notice');
const text = el('div');
text.append(el('h2', 'Bring older images into your prompt history'),
el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.'));
const actions = el('div', undefined, 'row-actions');
const later = createButton('Later');
const review = createButton('Review scan', 'ca-primary');
scope.listen(later, 'click', onLater);
scope.listen(review, 'click', () => onReview(review));
actions.append(later, review);
notice.append(text, actions);
return notice;
}
/** How many output folders a scan reads, from GET /settings; null when the answer does not say. */
export function sourceCount(settings) {
const count = settings?.sources?.effective?.length;
return Number.isSafeInteger(count) ? count : null;
}
/**
* Mount the shell's notice in `container`, hidden until the first `showFor(route)`. The pressed button leaves with the
* notice, so focus moves to `main`. If the sources cannot be read, Review scan says so and the notice stays.
*/
export function mountOlderImagesNotice(container, { api, activity, scope, main, storage = defaultStorage, notify = showWorkspaceToast }) {
let onSources = true;
let reading = false;
const render = () => {
const show = !onSources && !noticeDismissed(storage);
container.hidden = !show;
if (!show) notice.remove();
else if (!notice.isConnected) container.append(notice);
};
const notice = noticeNode({ scope,
onLater: () => {
dismissNotice(storage);
main.focus({ preventScroll: true });
},
onReview: async button => {
if (reading) return;
reading = true;
button.setAttribute('aria-disabled', 'true');
let sources = null;
try {
sources = sourceCount(await api.get(SETTINGS_URL, { signal: scope.signal, cache: 'no-store' }));
} catch {
if (scope.signal.aborted) return;
}
reading = false;
button.removeAttribute('aria-disabled');
if (sources === null) {
notify(SOURCES_UNREADABLE);
return;
}
dismissNotice(storage);
main.focus({ preventScroll: true });
openMetadataScan({ scope, store: activity, sources, trigger: main });
} });
watchNoticeDismissal({ scope, storage, onDismissed: render });
return {
showFor(route) {
onSources = route.id === 'system' && route.section === 'sources';
render();
},
};
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Link to a prompt (#536, D181; the concept has no picker): search the Library, choose the prompt that made this image
* and, when the prompt has recipes, its negative. The server's refusals stay in the dialog. No negative is taken from
* the file: one recipe is preselected and visible, several must be chosen between, none means no negative.
*/
import { createButton } from '../shared/toolkit/core.js';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
export const SEARCH_DELAY_MS = 250;
export const RESULT_LIMIT = 20;
export const VARIANT_LIMIT = 40;
export const NO_NEGATIVE = 'none';
const LIBRARY_URL = '/prompt_manager/library';
const CHOOSE = '';
export const LINK_COPY = Object.freeze({
title: 'Link to a prompt',
sentence: name => `Choose the prompt that made ${name}. This records your choice; the file's metadata does not confirm it.`,
search: 'Search the Library',
searching: 'Searching…',
empty: 'No prompt in the Library matches.',
failed: 'The Library could not be searched. Try again.',
negative: 'Negative prompt',
chooseNegative: 'Choose a negative…',
noNegative: 'No negative',
negativeFailed: "This prompt's negatives could not be read. Choose it again.",
action: 'Link image',
cancel: 'Cancel',
});
const titleOf = prompt => prompt.title?.trim() || prompt.text;
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The Negative options of a prompt's recipes: its value is the recipe's pair id, or `NO_NEGATIVE`. */
export function negativeOptions(variants) {
const recipes = variants.filter(variant => !variant.explicit_empty)
.map(variant => ({ value: String(variant.id), label: variant.title?.trim() || variant.negative_text }));
const bare = variants.find(variant => variant.explicit_empty);
const none = { value: bare ? String(bare.id) : NO_NEGATIVE, label: LINK_COPY.noNegative };
if (recipes.length > 1) return [{ value: CHOOSE, label: LINK_COPY.chooseNegative }, ...recipes, none];
return [...recipes, none];
}
/** The body fields of the chosen Negative value. */
export const pairIdOf = value => (value === NO_NEGATIVE ? null : Number(value));
/** Open the dialog; `controller.link` records the choice and the dialog closes once it is recorded. */
export function openPromptLink({ api, scope, controller, filename, trigger }) {
// A re-read may open another item meanwhile (decided elsewhere): the dialog names this one, so it closes.
const itemId = controller.state.selectedId;
const stopWatching = controller.subscribe(state => { if (state.selectedId !== itemId && !closed) closeWorkspaceDialog(); });
let timer = null, search = null, variantsRead = null, chosen = null, closed = false;
const sentence = el('p', LINK_COPY.sentence(filename), 'small');
const input = el('input');
input.type = 'search';
const results = el('div', undefined, 'review-link-results');
results.setAttribute('role', 'group');
results.setAttribute('aria-label', 'Matching prompts');
const status = el('p', '', 'small');
status.setAttribute('role', 'status');
const select = el('select');
const negative = field(LINK_COPY.negative, select, 'review-link-negative');
negative.hidden = true;
const warning = el('p', '', 'warning-note');
warning.setAttribute('role', 'status');
warning.hidden = true;
const actions = el('div', undefined, 'dialog-actions review-link-actions');
const cancel = createButton(LINK_COPY.cancel);
const submit = createButton(LINK_COPY.action, 'ca-primary');
actions.append(cancel, submit);
const busy = () => controller.state.phase !== 'idle';
const ready = () => chosen !== null && !negative.hidden && select.value !== CHOOSE;
function syncSubmit() {
if (ready() && !busy()) submit.removeAttribute('aria-disabled');
else submit.setAttribute('aria-disabled', 'true');
}
function warn(text) {
warning.textContent = text;
warning.hidden = !text;
}
/** Every active recipe of the prompt, page by page. */
async function readVariants(id, signal) {
const variants = [];
let cursor = null;
do {
const query = new URLSearchParams({ variant_limit: String(VARIANT_LIMIT) });
if (cursor) query.set('variant_cursor', cursor);
const detail = await api.get(`${LIBRARY_URL}/${encodeURIComponent(id)}?${query}`, { signal, cache: 'no-store' });
variants.push(...(detail.variants ?? []));
cursor = detail.variants_next_cursor ?? null;
} while (cursor);
return variants;
}
async function choose(prompt, button) {
variantsRead?.abort();
const read = variantsRead = new AbortController();
chosen = prompt;
for (const node of results.children) node.setAttribute('aria-pressed', String(node === button));
negative.hidden = true;
warn('');
syncSubmit();
try {
const variants = await readVariants(prompt.id, read.signal);
if (read !== variantsRead || closed) return;
const options = negativeOptions(variants);
select.replaceChildren(...options.map(option => {
const node = el('option', option.label);
node.value = option.value;
return node;
}));
select.value = options[0].value;
negative.hidden = false;
} catch (error) {
if (error?.name === 'AbortError' || read !== variantsRead || closed) return;
warn(LINK_COPY.negativeFailed);
}
syncSubmit();
}
function show(items) {
status.textContent = items.length ? '' : LINK_COPY.empty;
results.replaceChildren(...items.map(prompt => {
const button = createButton(titleOf(prompt));
button.title = prompt.text;
button.setAttribute('aria-pressed', String(prompt.id === chosen?.id));
button.addEventListener('click', () => choose(prompt, button));
return button;
}));
}
async function find() {
search?.abort();
const read = search = new AbortController();
status.textContent = LINK_COPY.searching;
try {
const query = new URLSearchParams({ text: input.value.trim(), limit: String(RESULT_LIMIT) });
const page = await api.get(`${LIBRARY_URL}?${query}`, { signal: read.signal, cache: 'no-store' });
if (read === search && !closed) show(page.items ?? []);
} catch (error) {
if (error?.name !== 'AbortError' && read === search && !closed) status.textContent = LINK_COPY.failed;
}
}
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(find, SEARCH_DELAY_MS);
});
select.addEventListener('change', syncSubmit);
cancel.addEventListener('click', () => { if (!busy()) closeWorkspaceDialog(); });
submit.addEventListener('click', async () => {
if (submit.getAttribute('aria-disabled') === 'true') return;
warn('');
submit.setAttribute('aria-disabled', 'true');
const done = await controller.link({ itemId, positiveId: chosen.id, pairId: pairIdOf(select.value), title: titleOf(chosen) });
if (closed) return;
if (done) { closeWorkspaceDialog(); return; }
warn(controller.state.warning);
syncSubmit();
});
openContentDialog({ scope, title: LINK_COPY.title, trigger, focusTarget: input,
nodes: [sentence, field(LINK_COPY.search, input, 'review-link-search'), status, results, negative, warning, actions],
canClose: () => !busy(),
onClose: () => {
closed = true;
stopWatching();
clearTimeout(timer);
search?.abort();
variantsRead?.abort();
} });
syncSubmit();
find();
}
+133 -66
View File
@@ -5,12 +5,16 @@
* - The queue is read 40 at a time, oldest first; `total` is the exact pending count. The cursor is bound to the
* storage epoch only, so Load more keeps working while new items arrive.
* - Selecting an item reads its detail: one "Sampler <id> · <positive> / <negative>" option per candidate, unusable
* ones disabled, the first workable one chosen, then "Leave unresolved" (rulings R2/R3).
* ones disabled with the server's reason, the first workable one chosen (rulings R2/R3). "Leave unresolved" is its
* own action (#536 D179, `leaveUnresolved()`). With nothing workable `unreadable` says why (D180).
* - `link(choice)` (#536) records the Library prompt the reviewer chose, under the same capture and retry rules.
* - A recorded decision opens the item after it (else the one before) and re-reads the queue quietly: the page stays
* (#536, overturning ruling R7). `decisions` counts them, for the page's toast.
* - Apply takes a fresh capture of the file (`observe`), then posts the decision; the server releases a capture it
* used. A refused decision releases the capture and keeps the selection with the server's sentence as the warning;
* a restored storage or a changed queue reads the queue again. A request that got no answer keeps its capture and
* its exact body, and the next Apply of the same choice (`retry()`) resends that body once.
* - Leave for later writes nothing. Tagging the image (the shared Tag dialog) never changes the item.
* - Leave for later writes nothing and opens the next item, wrapping. Tagging the image (the shared Tag dialog) never changes the item.
* - `refresh()` re-reads the queue quietly when the server says the queue changed (F2: the review change stream:
* a scan or the monitor added items, another tab decided one). The open item, its choice and an unanswered decision
* stay; when the open item has left the queue the next one opens with a note. Refreshes asked for while one runs
@@ -47,7 +51,10 @@ export const COPY = Object.freeze({
detailLoading: 'Loading sampler paths…',
detailFailed: 'This item could not be read. Select it again to retry.',
moreFailed: 'More items could not be loaded. Try again.',
limited: 'This file has too many sampler paths to list. You can leave it unresolved.',
limited: 'This file has too many sampler paths to list. Link it to a prompt or leave it unresolved.',
// #536 D180: nothing to apply.
unreadable: count => `${count === 1 ? "This file's sampler path" : `None of this file's ${count} sampler paths`} could be traced to a complete prompt, so there is nothing to apply. Choose the prompt yourself, or leave it unresolved.`,
lostLink: 'The link may not have been saved. Press Link image again to send the same choice once more.',
unavailable: 'This file is not under a current image source, so it cannot be opened. You can leave it unresolved.',
lost: 'The decision may not have been saved. Apply again to send the same decision once more.',
storageChanged: 'Prompt storage was restored; reload the review queue.',
@@ -57,6 +64,7 @@ export const COPY = Object.freeze({
failed: 'The decision could not be saved. Try again.',
applied: 'Association accepted.',
leftUnresolved: 'Left unresolved.',
linked: title => `Linked to ${title}.`,
});
// Answers after which the queue itself is out of date: read it again.
@@ -76,19 +84,34 @@ export function optionLabel(candidate) {
const workable = candidate => candidate?.state === 'resolved' && typeof candidate.positive?.text === 'string' &&
typeof candidate.negative?.text === 'string';
/** The Sampler context options of a detail: its candidates (unusable ones disabled), then Leave unresolved. */
/** The Sampler context options of a detail: its candidates, unusable ones disabled with the server's reason. */
export function contextOptions(detail) {
const usable = detail.target !== null && !detail.unavailable;
const options = (Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => Object.freeze({
value: candidate.hash, label: optionLabel(candidate), disabled: !usable || !workable(candidate), candidate,
return Object.freeze((Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => {
const readable = workable(candidate);
return Object.freeze({
value: candidate.hash, candidate, disabled: !usable || !readable,
label: readable || !candidate.reason ? optionLabel(candidate) : `${optionLabel(candidate)} (${candidate.reason})`,
});
}));
options.push(Object.freeze({ value: UNRESOLVED, label: COPY.leaveUnresolved, disabled: false, candidate: null }));
return Object.freeze(options);
}
/** The prompt blocks of the chosen option: its two role texts, or the ambiguous status for Leave unresolved. */
/**
* When no candidate of an available, listed file can be applied: their count and each distinct reason with its count
* (first seen first). Null otherwise.
*/
export function unreadableOf(detail) {
const candidates = Array.isArray(detail.candidates) ? detail.candidates : [];
if (detail.limited || detail.unavailable || detail.target === null || candidates.some(workable)) return null;
const reasons = new Map();
for (const { reason } of candidates) if (reason) reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
return Object.freeze({ count: candidates.length,
reasons: Object.freeze([...reasons].map(([reason, count]) => Object.freeze({ reason, count }))) });
}
/** The prompt blocks of the chosen option: its two role texts, or nothing without a choice. */
function promptsOf(option) {
if (!option?.candidate) return { positive: { status: 'ambiguous' }, negative: { status: 'ambiguous' } };
if (!option?.candidate) return null;
const { positive, negative } = option.candidate;
return { positive: { status: 'resolved', text: positive.text }, negative: { status: 'resolved', text: negative.text } };
}
@@ -106,13 +129,13 @@ const isLost = error => !isAbort(error) && !error?.code && !(error?.status >= 40
const initial = () => ({
status: 'idle', epoch: null, total: 0, items: Object.freeze([]), nextCursor: null, more: 'idle',
selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
phase: 'idle', warning: '', canRetry: false, toast: null,
unreadable: null, phase: 'idle', warning: '', canRetry: false, toast: null, decisions: 0,
});
/**
* @param {{api: {get: Function, post: Function, del: Function}}} options
* @returns the controller: `state`, `subscribe(listener)`, `load()`, `refresh()`, `loadMore()`, `select(id)`, `choose(value)`,
* `sync(version)`, `apply()`, `retry()`, `leave()`, `dispose()`.
* `sync(version)`, `apply()`, `leaveUnresolved()`, `link(choice)`, `retry()`, `leave()`, `dispose()`.
*/
export function createReviewController({ api }) {
let state = Object.freeze(initial());
@@ -218,15 +241,16 @@ export function createReviewController({ api }) {
if (keep && !stale && (quiet || state.detail)) return;
if (quiet && left && !keep && pending?.id === left.id) {
// M5: the decision that got no answer was recorded (the server used its capture).
const choice = pending.body.choice;
const { toast } = pending;
pending = null;
set({ phase: 'done', warning: '', canRetry: false,
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
set({ warning: '', canRetry: false, toast, decisions: state.decisions + 1 });
if (items.length) await select(items[0].id);
else clearSelection();
} else if (quiet && left && !keep && items.length) {
set({ warning: COPY.decidedElsewhere(left.filename) });
await select(items[0].id, { keepWarning: true });
} else if (items.length) await select((keep ?? items[0]).id, { keepWarning });
else set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null });
else clearSelection();
} catch (error) {
if (read !== listRead || disposed || isAbort(error)) return;
// M7: a click this read took over can be made again.
@@ -244,6 +268,11 @@ export function createReviewController({ api }) {
}
}
function clearSelection() {
set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
unreadable: null });
}
/** The stream's `ready`: re-read only when the queue shown is older than `version`. */
function sync(version) {
if (disposed) return Promise.resolve();
@@ -320,14 +349,14 @@ export function createReviewController({ api }) {
dropPending();
const read = detailRead = new AbortController();
set({ selectedId: id, detail: null, detailStatus: 'loading', options: Object.freeze([]), choice: null, prompts: null,
canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
unreadable: null, canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
try {
const detail = await api.get(`${REVIEW_URL}/${encodeURIComponent(id)}`, { signal: signalOf(read), cache: 'no-store' });
if (read !== detailRead || disposed) return;
const options = contextOptions(detail);
const chosen = options.find(option => !option.disabled);
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen.value, prompts: promptsOf(chosen),
warning: (keepWarning && state.warning) || warningOf(detail) });
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen?.value ?? null,
prompts: promptsOf(chosen), unreadable: unreadableOf(detail), warning: (keepWarning && state.warning) || warningOf(detail) });
} catch (error) {
if (read !== detailRead || disposed || isAbort(error)) return;
if (RELOAD.has(error?.code)) { set({ warning: error.message }); await load({ keepWarning: true, stale: true }); return; }
@@ -339,7 +368,7 @@ export function createReviewController({ api }) {
const option = state.options.find(entry => entry.value === value);
if (!option || option.disabled || state.phase === 'applying') return;
// Another choice is another decision: an unanswered one is no longer resent.
if (pending && pending.body.choice !== value) { dropPending(); set({ canRetry: false }); }
if (pending && pending.key !== value) { dropPending(); set({ canRetry: false }); }
set({ choice: value, prompts: promptsOf(option) });
}
@@ -350,24 +379,35 @@ export function createReviewController({ api }) {
if (owed && !reloading && !disposed) refresh();
}
/** Send `body`; true once the decision is recorded. */
async function send(body) {
/** The item recorded: it leaves the page's queue, the one after it opens and the queue is read again. */
async function advance(id, total, toast) {
const index = state.items.findIndex(entry => entry.id === id);
const items = Object.freeze(state.items.filter(entry => entry.id !== id));
const next = items[index] ?? items[index - 1] ?? null;
set({ phase: 'idle', items, total, warning: '', toast, decisions: state.decisions + 1 });
owedRefresh = false;
if (next) await select(next.id);
else clearSelection();
// Not refresh(): an answer already on its way may predate the decision, and this read aborts it.
await load({ keepWarning: true, quiet: true });
}
/** Send one decision; true once it is recorded. */
async function send({ path, body, key, toast, lost = COPY.lost }) {
const id = state.selectedId;
const choice = body.choice;
set({ phase: 'applying', canRetry: false });
try {
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/decision`, body, { signal: lifetime.signal });
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/${path}`, body, { signal: lifetime.signal });
pending = null;
set({ phase: 'done', total: result.total, warning: '',
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
await advance(id, result.total, toast);
return true;
} catch (error) {
// Leaving the page mid-request: the server may never see it, so the capture is released here (a release
// of a capture the server already used is a harmless 404).
if (disposed || isAbort(error)) { release(body.capture_token); return false; }
if (isLost(error)) {
pending = { id, body };
set({ phase: 'idle', warning: COPY.lost, canRetry: true });
pending = { id, path, body, key, toast, lost };
set({ phase: 'idle', warning: lost, canRetry: true });
idleAgain(false);
return false;
}
@@ -380,55 +420,82 @@ export function createReviewController({ api }) {
}
}
async function apply() {
const { detail, choice } = state;
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail || !choice) return false;
// After an unanswered request, Apply resends that same decision once (its capture and body).
if (pending?.id === state.selectedId && pending.body.choice === choice) return retry();
/** A fresh capture of the open file, or null when there is no target; `false` when the decision must stop. */
async function capture(detail) {
if (!detail.target) return null;
let observed;
try {
// M4: not aborted on leave, so the capture it answers can be released.
observed = await api.post(OBSERVE_URL, { target: detail.target });
} catch (error) {
if (disposed || isAbort(error)) return false;
set({ phase: 'idle', warning: error?.message || COPY.failed });
idleAgain(RELOAD.has(error?.code));
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
return false;
}
const taken = observed.capture;
if (disposed) { release(taken?.token); return false; }
// M3: the file was observed under another storage than the detail was read from.
if (observed.library_epoch !== detail.library_epoch) {
release(taken?.token);
set({ phase: 'idle', warning: COPY.storageChanged });
idleAgain(true);
await load({ keepWarning: true, stale: true });
return false;
}
return taken ?? null;
}
/** One decision from the idle page: resend an unanswered same one, else capture and send. */
async function decide({ key, path, fields, toast, lost }) {
const { detail } = state;
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail) return false;
// After an unanswered request, the same decision resends that body once (its capture and body).
if (pending?.id === state.selectedId && pending.key === key) return retry();
dropPending();
set({ phase: 'applying', warning: '', canRetry: false });
let capture = null;
if (detail.target) {
let observed;
try {
// M4: not aborted on leave, so the capture it answers can be released.
observed = await api.post(OBSERVE_URL, { target: detail.target });
} catch (error) {
if (disposed || isAbort(error)) return false;
set({ phase: 'idle', warning: error?.message || COPY.failed });
idleAgain(RELOAD.has(error?.code));
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
return false;
}
capture = observed.capture;
if (disposed) { release(capture?.token); return false; }
// M3: the file was observed under another storage than the detail was read from.
if (observed.library_epoch !== detail.library_epoch) {
release(capture?.token);
set({ phase: 'idle', warning: COPY.storageChanged });
idleAgain(true);
await load({ keepWarning: true, stale: true });
return false;
}
}
return send({
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: capture?.token ?? null,
sha256: capture?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, choice,
});
const taken = await capture(detail);
if (taken === false) return false;
return send({ path, key, toast, lost, body: {
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: taken?.token ?? null,
sha256: taken?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, ...fields,
} });
}
function apply() {
const { choice } = state;
if (!choice) return Promise.resolve(false);
return decide({ key: choice, path: 'decision', fields: { choice }, toast: COPY.applied });
}
function leaveUnresolved() {
return decide({ key: UNRESOLVED, path: 'decision', fields: { choice: UNRESOLVED }, toast: COPY.leftUnresolved });
}
/** Link item `itemId`, which must still be the open one, to prompt `positiveId` with recipe `pairId` (null: no negative). */
function link({ itemId, positiveId, pairId, title }) {
if (state.selectedId !== itemId || !state.detail?.target) return Promise.resolve(false);
return decide({ key: `link:${positiveId}:${pairId}`, path: 'link', toast: COPY.linked(title), lost: COPY.lostLink,
fields: { positive_id: positiveId, pair_id: pairId } });
}
/** Resend the unanswered decision once, with its own capture; false when there is nothing to resend. */
async function retry() {
if (!pending || state.phase !== 'idle' || pending.id !== state.selectedId) return false;
const { body } = pending;
const decision = pending;
pending = null;
return send(body);
return send(decision);
}
/** Leave for later: nothing is written; the next item opens, wrapping. */
function leave() {
if (state.phase === 'applying') return;
if (state.phase === 'applying') return Promise.resolve();
dropPending();
set({ phase: 'done', toast: null, canRetry: false });
const index = state.items.findIndex(entry => entry.id === state.selectedId);
const next = state.items[index + 1] ?? state.items[0];
if (!next || next.id === state.selectedId) { set({ canRetry: false, warning: '' }); return Promise.resolve(); }
return select(next.id);
}
return {
@@ -437,7 +504,7 @@ export function createReviewController({ api }) {
listeners.add(listener);
return () => listeners.delete(listener);
},
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, retry, leave,
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, leaveUnresolved, link, retry, leave,
dispose() {
if (disposed) return;
dropPending();
+50 -22
View File
@@ -7,8 +7,11 @@
* "No images need a decision" and the scan/new-generation sentence, and nothing else.
* - `.gallery-layout`: the queue's concept media cards (image-card.mjs; the image opens the shared viewer, Info selects
* the item, Tag opens the shared Tag image dialog with the review scope note), then `section.surface` "Resolve the
* association": filename, Tag image, Sampler context, the chosen path's prompt blocks and the actions Leave for later
* and Apply association. Both return to Images (ruling R7).
* association": filename, Tag image, Link to a prompt…, Sampler context, the chosen path's prompt blocks and the
* actions Leave for later, Leave unresolved and Apply association. Each opens the next item (#536, overturning R7).
* - D179: Leave unresolved is a button, not the select's last option; a disabled option names the server's reason.
* - D180: with nothing to apply the select, the prompt blocks and Apply are gone: the panel says why and Link to a
* prompt… is the primary action.
* - States the concept lacks (loading, a failed read, a refused or unanswered decision, a limited or unavailable file)
* are text: `p.warning-note` in the panel, or the page's status line (D119).
* - The open page re-reads its queue on each `change` of the review change stream (`/prompt_manager/review/events`,
@@ -29,6 +32,7 @@
import { createButton } from '../shared/toolkit/core.js';
import { createReviewController, COPY, noticeTitle } from './review-state.mjs';
import { createImageCard } from './image-card.mjs';
import { openPromptLink } from './review-link.mjs';
import { isTagButton, openImageTagging } from './image-tagging.mjs';
import { mountPromptBlocks } from './prompt-blocks.mjs';
import { createPromptSender } from './prompt-sender.mjs';
@@ -38,6 +42,7 @@ import { createWorkspaceIcon } from './icons.mjs';
import { field } from './workspace-dialogs.mjs';
import { pageHeading } from './heading.mjs';
import { navigateTo } from './state.mjs';
import { noun } from './plural.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imageKey } from './images-state.mjs';
@@ -123,17 +128,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
const tagRow = el('div', undefined, 'row-actions spaced-15');
const tagButton = createButton('');
tagButton.append(createWorkspaceIcon('tagImage'), ' Tag image');
tagRow.append(tagButton);
const linkButton = createButton('Link to a prompt…');
linkButton.classList.add('review-link-open');
tagRow.append(tagButton, linkButton);
const select = el('select');
const contextField = field('Sampler context', select, 'review-context');
const unreadable = el('div', undefined, 'review-unreadable'); // D180
const warning = el('p', '', 'warning-note'); // D119: the states the concept lacks
warning.setAttribute('role', 'status');
const blocks = el('div');
const actions = el('div', undefined, 'dialog-actions');
const later = createButton('Leave for later');
const leaveUnresolved = createButton('Leave unresolved');
leaveUnresolved.classList.add('review-leave-unresolved');
const apply = createButton('Apply association', 'ca-primary');
actions.append(later, apply);
panel.append(panelTitle, filename, tagRow, contextField, warning, blocks, actions);
actions.append(later, leaveUnresolved, apply);
panel.append(panelTitle, filename, tagRow, contextField, unreadable, warning, blocks, actions);
layout.append(grid, panel);
const live = el('p', NOT_LIVE, 'small review-live'); // D120: the change stream is down
@@ -155,7 +165,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
sender, onSelect: image => inspection.select(image) });
const cards = new Map();
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail;
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail, shownUnreadable = null, shownDecisions = 0;
function createCard(item) {
// D119: no selection checkbox and no star on a queue card.
@@ -192,10 +202,26 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
}
}
/** D180: nothing to apply, so say why and lead with the picker. */
function renderUnreadable(state) {
const none = state.detailStatus === 'ready' && !state.choice;
contextField.hidden = blocks.hidden = apply.hidden = none;
linkButton.classList.toggle('ca-primary', none && Boolean(state.detail?.target));
unreadable.hidden = !state.unreadable;
if (state.unreadable === shownUnreadable) return;
shownUnreadable = state.unreadable;
if (!state.unreadable) { unreadable.replaceChildren(); return; }
const { count, reasons } = state.unreadable;
unreadable.replaceChildren(el('p', COPY.unreadable(count)),
...reasons.map(({ reason, count: paths }) => el('p', `${reason} (${paths} ${noun(paths, 'path')})`, 'small')));
}
function renderPanel(state) {
const item = state.items.find(entry => entry.id === state.selectedId);
setText(filename, item?.filename ?? '');
setDisabled(tagButton, !item?.target || state.phase === 'applying');
setDisabled(linkButton, !item?.target || state.detailStatus !== 'ready' || state.phase !== 'idle');
renderUnreadable(state);
if (state.options !== shownOptions) {
shownOptions = state.options;
select.replaceChildren(...state.options.map(option => {
@@ -212,16 +238,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
if (state.prompts !== shownPrompts || state.detailStatus !== shownDetail) {
shownPrompts = state.prompts;
shownDetail = state.detailStatus;
if (state.detailStatus === 'ready') prompts.setState({ prompts: state.prompts });
if (state.detailStatus === 'ready' && state.prompts) prompts.setState({ prompts: state.prompts });
else if (state.detailStatus === 'ready') prompts.setState({ prompts: null });
else prompts.setState({ loading: state.detailStatus === 'loading', error: state.detailStatus === 'error' });
}
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle');
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle' || !state.choice);
setDisabled(leaveUnresolved, state.detailStatus !== 'ready' || state.phase !== 'idle');
setDisabled(later, state.phase === 'applying');
}
function render(state) {
if (scope.signal.aborted) return;
if (state.phase === 'done') return finish(state);
if (state.decisions !== shownDecisions) {
shownDecisions = state.decisions;
showWorkspaceToast(state.toast);
onDecision();
}
const ready = state.status === 'ready';
if (!ready) {
setText(status, state.status === 'error' ? COPY.loadFailed : COPY.loading);
@@ -249,23 +281,17 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
} else continuation.remove();
}
let finished = false;
function finish(state) {
if (finished) return;
finished = true;
if (state.toast) {
showWorkspaceToast(state.toast);
onDecision();
}
// Ruling R7: Apply association and Leave for later both return to Images.
navigateTo('#/images');
}
scope.listen(retryLoad, 'click', () => controller.load());
scope.listen(more, 'click', () => { if (!isDisabled(more)) controller.loadMore(); });
scope.listen(select, 'change', () => controller.choose(select.value));
scope.listen(apply, 'click', () => { if (!isDisabled(apply)) controller.apply(); });
scope.listen(later, 'click', () => { if (!isDisabled(later)) controller.leave(); });
scope.listen(leaveUnresolved, 'click', () => { if (!isDisabled(leaveUnresolved)) controller.leaveUnresolved(); });
scope.listen(linkButton, 'click', () => {
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
if (isDisabled(linkButton) || !item?.target) return;
openPromptLink({ api, scope, controller, filename: item.filename, trigger: linkButton });
});
scope.listen(tagButton, 'click', () => {
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
if (isDisabled(tagButton) || !item?.target) return;
@@ -288,11 +314,13 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
}
// Info selects the item, as the concept's `inspect` does on this page (app.js a1244–1248).
controller.select(item.id);
// D189: at one column the panel cannot stay beside the queue, so Info brings it into view.
if (getComputedStyle(panel).position === 'static') panel.scrollIntoView({ block: 'nearest' });
});
let shownTotal = null;
const reportTotal = state => {
if (state.status !== 'ready' || state.phase === 'done' || state.total === shownTotal) return;
if (state.status !== 'ready' || state.total === shownTotal) return;
shownTotal = state.total;
onTotal?.(state.total);
};
+34 -23
View File
@@ -1,14 +1,15 @@
export const routes = [
{ id: 'images', label: 'Images', href: '/prompt_manager/gallery',
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
description: 'Find, edit and organize the prompts already in your library.', action: 'Open existing prompt library' },
// Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card.
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
// Native screen (#249 local Inspect); the href stays for Overview's card to the legacy metadata page.
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'A standalone file uses the same image viewer and prompt actions.', action: 'Open existing metadata inspector' },
// `href` and `action` are Overview's card (#525): every card opens its native page, never a legacy one.
{ id: 'images', label: 'Images', href: '#/images',
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open Images' },
{ id: 'library', label: 'Prompt library', href: '#/library',
description: 'Find, edit and organize the prompts already in your library.', action: 'Open Prompt library' },
// `title` is the concept's breadcrumb/title.
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open Tags & categories' },
// #249 local Inspect.
{ id: 'inspect', label: 'Inspect a file', href: '#/inspect',
description: 'A standalone file uses the same image viewer and prompt actions.', action: 'Open Inspect a file' },
{ id: 'overview', label: 'Overview' },
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
{ id: 'favorites', label: 'Favorites' },
@@ -18,13 +19,20 @@ export const routes = [
{ id: 'review', label: 'Needs review', title: 'Review' },
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
{ id: 'activity', label: 'Activity' },
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section (`native`:
// a workspace hash, not a legacy page); every section is native (S12 retired D94's hand-off). The description is
// the concept's System heading (app.js renderSystem 700).
{ id: 'system', label: 'System', href: '#/system/overview', native: true,
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section; every
// section is native (S12 retired D94's hand-off). The description is the concept's System heading (app.js renderSystem 700).
{ id: 'system', label: 'System', href: '#/system/overview',
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
];
/** D165 (#525): the legacy pages, which stay at their URLs; System's Overview section links them discreetly. */
export const LEGACY_PAGES = Object.freeze([
{ label: 'Dashboard', href: '/prompt_manager/admin' },
{ label: 'Tags', href: '/prompt_manager/admin#/tags' },
{ label: 'Gallery', href: '/prompt_manager/gallery' },
{ label: 'Metadata viewer', href: '/prompt_manager/gallery.html' },
].map(page => Object.freeze(page)));
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
'collection']);
@@ -87,15 +95,6 @@ export function resolveRoute(hash) {
return routes.find(route => hash === `#/${route.id}`) || routes[0];
}
export function summarizeStats(stats) {
return [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]
.map(([label, key]) => {
const count = stats?.[key];
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid library statistics');
return [label, count];
});
}
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
// Favorites page's own listing, so its count is what that page lists.
const NAV_COUNT_SOURCES = Object.freeze([
@@ -150,6 +149,18 @@ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_C
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
}
// #547: each Overview card is the count of the page it names, the same number as that page's sidebar badge.
const OVERVIEW_CARDS = Object.freeze([['Prompts', 'library'], ['Images', 'images'], ['Tags', 'tags']]);
export const OVERVIEW_COUNT_ROUTES = Object.freeze(OVERVIEW_CARDS.map(([, id]) => id));
/** Overview's state from the shell's counts: no entry is a read not settled yet, null a read that failed. */
export function summarizeOverviewCounts(counts) {
const cards = OVERVIEW_CARDS.map(([label, id]) => [label, counts.get(id) ?? null]);
const pending = OVERVIEW_COUNT_ROUTES.some(id => !counts.has(id));
const failed = OVERVIEW_COUNT_ROUTES.some(id => counts.get(id) === null);
return { cards, pending, failed, empty: !pending && cards.every(([, count]) => count === 0) };
}
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
+91
View File
@@ -0,0 +1,91 @@
/**
* The chrome that stays with you as a page scrolls (#531, beyond the concept; TESTING.md D186 and D187).
*
* - `keepingPlace`: repaint a sticky selection bar without moving the cards under the pointer.
* - `publishBarHeight`: tell the root how much of the viewport's top a shown bar takes, for the Images panel's sticky
* offset and for `scroll-margin` (a focused card is not left under the bar).
* - `mountBackToTop`: the shell's chip, which rests above the toast's spot and rises only for a taller toast.
*/
/** How far off the top of the page the chip appears, in CSS pixels. */
export const BACK_TO_TOP_AFTER = 200;
/** The gap between the stuck bar and whatever it clears (its `inset-block-start` above, and as much below). */
const BAR_GAP = 16;
const TOAST_GAP = 8;
/**
* Run `paint`, which may change the height of `element`. While the element's place in the page's flow is above the
* viewport (the bar is stuck), scroll by the difference, so the content below stays where it was. On screen, the bar
* pushes the content as the concept's does. Native scroll anchoring would add its own correction in some browsers, so
* it is off until the next frame. Returns the distance scrolled.
*/
export function keepingPlace(element, paint) {
const next = element.nextElementSibling;
if (!next || !element.isConnected) { paint(); return 0; }
const before = next.getBoundingClientRect().top;
const root = document.documentElement;
const anchoring = root.style.overflowAnchor;
root.style.overflowAnchor = 'none';
paint();
const shift = next.getBoundingClientRect().top - before;
requestAnimationFrame(() => { root.style.overflowAnchor = anchoring; });
if (!shift || before >= Math.max(0, element.getBoundingClientRect().bottom)) return 0;
const from = window.scrollY;
window.scrollBy({ top: shift, behavior: 'instant' });
return window.scrollY - from;
}
/** Publish the height of the bar inside `element` as `--selection-bar-block` while it shows; cleared with `scope`. */
export function publishBarHeight(scope, element) {
const root = document.documentElement;
const clear = () => root.style.removeProperty('--selection-bar-block');
const update = () => {
const height = element.getBoundingClientRect().height;
if (height > 0) root.style.setProperty('--selection-bar-block', `${height + BAR_GAP}px`);
else clear();
};
const observer = new ResizeObserver(update);
observer.observe(element);
scope.cleanup(() => { observer.disconnect(); clear(); });
}
/** Go to the top of the page at once: the pages' own height corrections would cancel a smooth scroll part way. */
export function scrollToPageTop() {
window.scrollTo({ top: 0, behavior: 'instant' });
}
/** One chip for the whole shell: shown off the top of any page; pressing it goes to the top at once. */
export function mountBackToTop({ scope, toast }) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'back-to-top';
button.hidden = true;
const arrow = document.createElement('span');
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '↑';
button.append(arrow, ' Back to top');
toast.after(button);
// Scroll events already come once a frame, and this only reads `scrollY`.
const update = () => { button.hidden = window.scrollY <= BACK_TO_TOP_AFTER; };
scope.listen(window, 'scroll', update, { passive: true });
scope.listen(button, 'click', () => {
// Never animated, so reduced motion holds. The chip hides under the focus, so focus moves to the page.
scrollToPageTop();
document.getElementById('workspace-main')?.focus({ preventScroll: true });
update();
});
const root = document.documentElement;
const followToast = () => {
const height = toast.getBoundingClientRect().height;
if (height > 0) root.style.setProperty('--toast-block', `${height + TOAST_GAP}px`);
else root.style.removeProperty('--toast-block');
};
const toastObserver = new MutationObserver(followToast);
toastObserver.observe(toast, { childList: true, characterData: true, subtree: true });
scope.listen(window, 'resize', followToast);
scope.cleanup(() => { toastObserver.disconnect(); root.style.removeProperty('--toast-block'); button.remove(); });
update();
return button;
}
+7 -39
View File
@@ -2,8 +2,9 @@
* System › Sources & scans (#249 System S4 Task 11): the concept's `renderSystem` "sources" body (app.js 679–681).
*
* - The optional `.notice` "Bring older images into your prompt history" shows until this browser dismissed the 3.2.0
* update notice (`pm_update_notice_dismissed`, the legacy admin page's own key). Later dismisses it without scanning;
* Review scan dismisses it, as the legacy page's Scan does, and reviews every source.
* update notice (older-images-notice.mjs, the legacy admin page's own key). Later dismisses it without scanning;
* Review scan dismisses it, as the legacy page's Scan does, and reviews every source. D166: dismissing it in the shell
* or another tab removes it here too, and the shell does not show it on this section.
* - "Image sources" lists the server's sources (GET /settings, S4 Task 9) in `#source-list`, one `.operation` each:
* its label, "<path> · watching | watching until restart | manual scan | restart pending" and "Scan changes"
* (watched) or "Scan folder", which opens the shared "Review metadata scan" (D55) for that one source ("1 output
@@ -38,14 +39,15 @@ import { settingRow, operationRow } from './system-controls.mjs';
import { HOST_UNAVAILABLE } from './system-appearance.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { noun } from './plural.mjs';
import { dismissNotice, noticeDismissed, noticeNode, watchNoticeDismissal } from './older-images-notice.mjs';
export { NOTICE_KEY, NOTICE_VERSION, dismissNotice, noticeDismissed } from './older-images-notice.mjs';
const SETTINGS_URL = '/prompt_manager/settings';
const VALIDATE_URL = '/prompt_manager/settings/validate-source';
const RESCAN_URL = '/prompt_manager/gallery/rescan';
const ROOTS_KEY = 'gallery_root_paths';
export const NOTICE_KEY = 'pm_update_notice_dismissed';
export const NOTICE_VERSION = '3.2.0';
export const WATCH_COPY = Object.freeze({ watching: 'watching', stopping: 'watching until restart', manual: 'manual scan',
restart_pending: 'restart pending' });
// The states whose observer runs now: their scan only has to catch up on changes.
@@ -87,25 +89,6 @@ const addedLabels = new Map();
const defaultStorage = () => window.localStorage;
/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */
export function noticeDismissed(getStorage = defaultStorage) {
try {
return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION;
} catch {
return false;
}
}
/** Dismiss the notice for this browser; false when storage refused (it stays dismissed for this view). */
export function dismissNotice(getStorage = defaultStorage) {
try {
getStorage().setItem(NOTICE_KEY, NOTICE_VERSION);
return true;
} catch {
return false;
}
}
const sameRoots = (a, b) => Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((path, index) => path === b[index]);
/** The roots to draft: the saved list itself when they are equal, so the draft is clean again. */
@@ -168,22 +151,6 @@ function el(tag, text, className) {
return node;
}
/** The concept's `.notice` with Later and Review scan. */
function noticeNode({ scope, onLater, onReview }) {
const notice = el('div', undefined, 'notice');
const text = el('div');
text.append(el('h2', 'Bring older images into your prompt history'),
el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.'));
const actions = el('div', undefined, 'row-actions');
const later = createButton('Later');
const review = createButton('Review scan', 'ca-primary');
scope.listen(later, 'click', onLater);
scope.listen(review, 'click', onReview);
actions.append(later, review);
notice.append(text, actions);
return notice;
}
/**
* "Add image source" (the concept's `simpleInput`, D127): the server checks the folder; a refused one keeps the
* dialog open with the server's reason under the field. Enter in the field adds, as the Add button does.
@@ -263,6 +230,7 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
notice.remove();
scanAll(addButton);
} });
watchNoticeDismissal({ scope, storage, onDismissed: () => notice.remove() });
nodes.push(notice);
}

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