Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95478a0aed | ||
|
|
df0a798eea | ||
|
|
2ebc5d9280 | ||
|
|
7f414e7c45 | ||
|
|
096e2472bd | ||
|
|
4a25db545c | ||
|
|
cdaf4122de | ||
|
|
4be48c2ee5 | ||
|
|
e272c84efd | ||
|
|
494a5d0bfa | ||
|
|
06ea91da66 | ||
|
|
07c2442e23 | ||
|
|
e6e425ed35 | ||
|
|
db1c7ef20d | ||
|
|
0f2f88d48c |
@@ -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
|
||||
|
||||
+158
-26
@@ -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
|
||||
@@ -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
|
||||
@@ -3396,15 +3463,20 @@ document title "Review" (the concept's), heading "Metadata review" with its line
|
||||
concept's sentence, then `.gallery-layout`: the queue's concept media cards
|
||||
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
|
||||
the item, Tag opens the shared Tag image dialog with the review scope note) and
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Sampler context,
|
||||
the chosen path's prompt blocks, Leave for later and primary Apply association). The
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Link to a prompt…,
|
||||
Sampler context, the chosen path's prompt blocks, Leave for later, Leave unresolved and
|
||||
primary Apply association). The
|
||||
first item is selected on load, as the concept's first image is. Sampler context lists
|
||||
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
|
||||
unusable ones, preselects the first workable one and ends with "Leave unresolved"
|
||||
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
|
||||
unusable ones with the server's reason and preselects the first workable one
|
||||
(rulings R2/R3). Apply
|
||||
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
|
||||
under no current root posts without a capture. Both actions return to Images (R7); a
|
||||
recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
under no current root posts without a capture. Each decision and Leave for later opens
|
||||
the next item and the page stays (#536 overturns ruling R7); Leave for later writes
|
||||
nothing. Link to a prompt… (#536) records a Library prompt the reviewer chose,
|
||||
`POST /prompt_manager/review/items/{id}/link`: the capture guards of Apply, the same
|
||||
one-prompt-per-file rules, and link provenance `kind: "manual-link"` with no prompt
|
||||
sides, so it never reads as file evidence. A recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
refused decision keeps the selection and shows the server's sentence (the
|
||||
association conflict's explanation included) as `p.warning-note` in the panel; a
|
||||
restored storage or a changed queue reads the queue again; a request with no answer
|
||||
@@ -3419,6 +3491,14 @@ pending count (`GET /prompt_manager/review/items?limit=1`, its `total`).
|
||||
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
|
||||
item after the item's line, and in its job view's heading after Download report. The
|
||||
item's chip still names its state (D54), where the concept's says "Needs attention".
|
||||
- **D179** (#536, maintainer decision 2026-10-30): "Leave unresolved" is a button beside
|
||||
Leave for later, not the Sampler context select's last option; a disabled option names
|
||||
the server's reason.
|
||||
- **D180** (#536): with nothing to apply, the select, the prompt blocks and Apply are
|
||||
hidden; the panel gives the path count and each reason, and Link to a prompt… is primary.
|
||||
- **D181** (#536): the concept has no picker. Link to a prompt… opens the shared content
|
||||
dialog: Library search, the prompt's recipes as Negative prompt (one preselected,
|
||||
several must be chosen, always "No negative"), Cancel and Link image.
|
||||
- **D119**: the queue cards have no selection checkbox and
|
||||
no star (the page has no selection bar and the queue carries no favorite state); a
|
||||
file under no current root has an empty, `aria-disabled` image button and Tag; more
|
||||
@@ -3992,6 +4072,23 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||
- **D170** (#532, maintainer request 2026-10-30, beyond the concept): every viewer has
|
||||
`button#viewer-keys.icon-btn` right before the D156 view switch (accessible name and
|
||||
`title` "Keyboard shortcuts", a `command` outline icon, hidden at ≤700px with the
|
||||
switch). It opens `dialog#viewer-keys-dialog`, a modal built on demand inside the
|
||||
viewer, so a closed overlay adds nothing to the concept comparisons but the button:
|
||||
`.dialog-header` with × and the keys grouped as Move (← → Space), Image (+ - 0 R H),
|
||||
View (F I ? Esc) and File (D, Delete where the viewer has Delete…), one column at
|
||||
390px. Esc or × closes only the overlay and focus returns to the button; opening it
|
||||
stops the slideshow. ← and → navigate from anywhere in the viewer, on `document` as the
|
||||
legacy gallery does, unless a modifier is held, text is selected, another dialog is on
|
||||
top, or focus is in a video, form field, `pre`, `summary`, or anything in the details
|
||||
but a button or link. The other keys share that gate but allow Shift, and each clicks
|
||||
the toolbar's own control, so an aria-disabled, missing or hidden control does nothing
|
||||
(Rotate on a video, I when Centered, F at ≤700px, Delete without Delete…). `?` opens
|
||||
the overlay directly. Delete only opens the review. Space on a focused button or link
|
||||
activates it instead, and only + and - repeat. `workspace-viewer-keys.spec.mjs` pins
|
||||
this.
|
||||
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
|
||||
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
|
||||
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
|
||||
@@ -4235,6 +4332,24 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
||||
long file name keeps the concept's two equal columns at phone width.
|
||||
- **D173** (#535, amends D106; maintainer 2026-10-30): "always asks" now means asks when
|
||||
there is a choice. One eligible window is selected, and a selected window with exactly
|
||||
one eligible node selects that node; two or more nodes are never selected. A node whose
|
||||
requested field is linked is not offered (`linkedRoles` on each destination), and the
|
||||
status line says which case applies: linked, wrong roles, no node, or no window. It
|
||||
counts the other nodes that cannot receive, and the review names the node and window.
|
||||
- **D174** (#535): the window option shows the tab's current title, read at each discovery
|
||||
(a getter on the host options, which the Toolkit reads per discover), without the old
|
||||
"PromptManager —" prefix. Same-named windows get " (window 2)", numbered by host id so the
|
||||
number is stable while the tabs stay loaded. Every node option reads "#<id> <title>", so
|
||||
identical nodes can be told apart. Discovery waits 300 ms, as System does.
|
||||
- **D175** (#535, maintainer 2026-10-30): the concept README previews before applying;
|
||||
production makes Preview optional. Apply is enabled once a window and node are selected
|
||||
and requests the ticket itself, in one click. It stays closed after "Sent to ComfyUI.",
|
||||
so a double click sends once, and after an unknown outcome until a new preview or
|
||||
choice. The host's checks still refuse a linked field or a changed workflow, and the
|
||||
sender also refuses if the destination's `revision` (node object plus graph version)
|
||||
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
|
||||
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
||||
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
|
||||
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
|
||||
@@ -5626,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
|
||||
|
||||
+16
-3
@@ -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
@@ -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,
|
||||
|
||||
@@ -18,9 +18,13 @@ protocol and peer checks reject unrelated messages. Same-origin scripts are
|
||||
trusted; this transport is not authentication against other scripts on that origin.
|
||||
Unsupported messaging is reported without breaking existing ComfyUI nodes.
|
||||
|
||||
Apply works in one click: without a preview, the sender requests the ticket itself
|
||||
and applies it. Preview remains optional, and shows the exact change first.
|
||||
|
||||
An apply timeout or abort after posting means the outcome is unknown. It cannot
|
||||
prove the host cancelled the write. The sender must inspect a fresh destination
|
||||
preview before deciding to send again; automatic retry must not overwrite edits.
|
||||
preview or choose the destination again before sending again; automatic retry must
|
||||
not overwrite edits. An error the host returned is a refusal: nothing was written.
|
||||
|
||||
## PromptManager adapter
|
||||
|
||||
@@ -31,7 +35,9 @@ field. Unknown/ambiguous metadata must not become an empty string.
|
||||
|
||||
Previews show original and requested text plus positive prepend/append composition.
|
||||
Connected composition is marked unknown rather than guessing upstream values.
|
||||
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
|
||||
Connected destination fields reject writes. The sender compares the destination's revision
|
||||
(node object and graph version) from discovery with the one at the preview, and refuses if the workflow changed.
|
||||
Applying rechecks graph, node, widgets,
|
||||
values and connections. Ordinary serialization cache synchronization is accepted
|
||||
only when it preserves live values. Both requested roles are checked before writing.
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -362,10 +362,14 @@ test('Show in folder asks the server to open the listed file and announces each
|
||||
const body=sent.postDataJSON();
|
||||
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
|
||||
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
|
||||
await expect(toast).toHaveText('Opened the folder on this computer.');
|
||||
await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
|
||||
// #533: the answer is really on screen and reachable above the modal viewer, not covered by it.
|
||||
await expect(toast).toBeVisible();
|
||||
expect(await toast.evaluate(node=>{const box=node.getBoundingClientRect();const top=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);return top===node;})).toBe(true);
|
||||
await expect(page.locator('#viewer-dialog').getByRole('status').filter({hasText:'Opened the folder on the computer running ComfyUI.'})).toHaveCount(1);
|
||||
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
|
||||
for (const [outcome,message] of [['unavailable','No file manager is available on the computer running ComfyUI.'],['fail','The folder could not be opened. Try again.']]) {
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
|
||||
await show.click();await expect(toast).toHaveText(message);
|
||||
}
|
||||
@@ -373,11 +377,56 @@ test('Show in folder asks the server to open the listed file and announces each
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
|
||||
await show.click();await expect(toast).toHaveText('The folder is already opening.');
|
||||
await page.unroute('**/prompt_manager/images/reveal');
|
||||
// A proxied request counts as remote, so the real route answers 403 not-local.
|
||||
// A proxied click opens the folder: the proxy's headers, not the browser's address, name the page (#533).
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
|
||||
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
await show.click();await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
|
||||
expect(await opened()).toHaveLength(2);
|
||||
await page.unroute('**/prompt_manager/images/reveal');
|
||||
// A plain cross-site POST is refused, and the toast says so.
|
||||
// Chromium keeps its own Origin on a routed fetch, so the real route is asked directly and the toast gets its answer.
|
||||
const refused=await request.post('/prompt_manager/images/reveal',{data:body,headers:{origin:'https://evil.example'}});
|
||||
expect(refused.status()).toBe(403);expect((await refused.json()).code).toBe('not-same-origin');
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:403,contentType:'application/json',body:JSON.stringify({success:false,code:'not-same-origin',error:"This page's address does not match the server's."})}));
|
||||
await show.click();await expect(toast).toHaveText("The folder was not opened: this page's address does not match the server's.");
|
||||
await expect(toast).toBeVisible();
|
||||
expect(await opened()).toHaveLength(2);
|
||||
// #533: closing the viewer takes the toast back to the page, still showing the message.
|
||||
await page.keyboard.press('Escape');await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
await expect(toast).toBeVisible();await expect(page.locator('body > #workspace-toast')).toHaveCount(1);
|
||||
});
|
||||
// #533 D176: real modals in the real top layer, with the page's own toast module.
|
||||
test('a toast stays on screen across nested, switched and closing modals',async({page,request})=>{
|
||||
await fullFixture(page,request);
|
||||
await page.evaluate(async()=>{
|
||||
const url=performance.getEntriesByType('resource').map(entry=>entry.name).find(name=>name.includes('/toast.mjs'));
|
||||
const {showWorkspaceToast}=await import(url);
|
||||
window.say=showWorkspaceToast;
|
||||
window.modal=(id,first=false)=>{const dialog=document.createElement('dialog');dialog.id=id;dialog.textContent=id;document.body[first?'prepend':'append'](dialog);dialog.showModal();return dialog;};
|
||||
window.onTop=()=>{const node=document.getElementById('workspace-toast');const box=node.getBoundingClientRect();return box.width>0&&document.elementFromPoint(box.x+box.width/2,box.y+box.height/2)===node;};
|
||||
});
|
||||
const parent=()=>page.evaluate(()=>{const host=document.getElementById('workspace-toast')?.parentElement;return host?host.id||host.localName:null;});
|
||||
const onTop=()=>page.evaluate(()=>window.onTop());
|
||||
await page.evaluate(()=>{window.modal('a');say('one');});
|
||||
expect(await parent()).toBe('a');expect(await onTop()).toBe(true);
|
||||
// Nested: the top one holds it, even earlier in the page than the one under it.
|
||||
await page.evaluate(()=>{window.modal('b',true);say('two');});
|
||||
expect(await parent()).toBe('b');expect(await onTop()).toBe(true);
|
||||
// The top one closes while the toast shows: the message stays, in the modal now on top.
|
||||
await page.evaluate(()=>document.getElementById('b').close());
|
||||
await expect.poll(parent).toBe('a');expect(await onTop()).toBe(true);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('two');
|
||||
// Switched: the next modal takes the next toast.
|
||||
await page.evaluate(()=>{document.getElementById('a').close();window.modal('c');say('three');});
|
||||
expect(await parent()).toBe('c');expect(await onTop()).toBe(true);
|
||||
// Closed and removed at once, as the workspace's dialogs do: home, still showing.
|
||||
await page.evaluate(()=>{const c=document.getElementById('c');c.close();c.remove();});
|
||||
await expect.poll(parent).toBe('body');expect(await onTop()).toBe(true);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('three');
|
||||
// Removed while open, with no close event: the next message still shows.
|
||||
await page.evaluate(()=>{window.modal('d');say('four');document.getElementById('d').remove();say('five');});
|
||||
expect(await parent()).toBe('body');expect(await onTop()).toBe(true);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('five');
|
||||
});
|
||||
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
|
||||
@@ -415,7 +464,7 @@ test('full inspector preserves long role text and exact sends',async({page,reque
|
||||
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
||||
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair Exact pair'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]);
|
||||
} finally {await host.evaluate(()=>window.bridge.dispose());await host.close();}
|
||||
@@ -627,6 +676,10 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
|
||||
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
|
||||
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
|
||||
production.paths=production.paths.filter(path=>!modePaths.includes(path));
|
||||
// #532 D170: production's shortcuts button sits right before the view switch; the concept has none.
|
||||
const keysPaths=production.paths.filter(path=>path.includes('button#viewer-keys'));
|
||||
expect(keysPaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-keys.icon-btn','button#viewer-keys.icon-btn > svg.icon','button#viewer-keys.icon-btn > svg.icon > path']);
|
||||
production.paths=production.paths.filter(path=>!keysPaths.includes(path));
|
||||
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
|
||||
expect(production.toolbar[8].text).toBe('Delete…');
|
||||
production.toolbar.splice(8,1);
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function exerciseCurrentPairSend(page, roles) {
|
||||
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#canonical-pair Canonical destination' });
|
||||
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
|
||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
|
||||
@@ -194,7 +194,7 @@ function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -259,7 +259,7 @@ async function productionSendOf(page,context) {
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
||||
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -233,7 +233,7 @@ function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
@@ -374,7 +374,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
|
||||
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
|
||||
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
|
||||
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
|
||||
expect(actual).toEqual(reference);
|
||||
// D186: at one column the bar is one row that scrolls sideways, with the same buttons.
|
||||
const mapped=width<=700?{...reference,bar:{...reference.bar,flexWrap:'nowrap',buttons:reference.bar.buttons.map((button,index)=>({...button,left:actual.bar.buttons[index].left,top:actual.bar.buttons[index].top}))}}:reference;
|
||||
expect(actual).toEqual(mapped);
|
||||
// The comparison notices a moved checkbox (spec §7 mutation check).
|
||||
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
|
||||
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
|
||||
|
||||
@@ -33,7 +33,7 @@ async function chooseDestination(page) {
|
||||
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
|
||||
await host.selectOption({label:'Test ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
|
||||
return sender;
|
||||
}
|
||||
|
||||
@@ -165,7 +165,8 @@ test('host apply rejection retains the current text and requires a new preview',
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
// D175: a refusal wrote nothing, so Apply may try again.
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();
|
||||
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
@@ -193,7 +194,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
|
||||
const sender=dialog(page);await expect(sender).toContainText('No ComfyUI window answered in this browser');
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -88,7 +88,7 @@ function measureIntegrations(mainSelector) {
|
||||
};
|
||||
}
|
||||
|
||||
const SEND_REASON = 'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.';
|
||||
const SEND_REASON = 'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.';
|
||||
const RENDERER_REASON = 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.';
|
||||
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
|
||||
const RETURN_REASON = 'Not available: sending applies the text in ComfyUI and keeps this page open.';
|
||||
|
||||
@@ -231,6 +231,9 @@ export function mapConceptReview(reference,actual) {
|
||||
const actualActions=findNode(actual.main,'div.dialog-actions');
|
||||
let cardIndex=0;
|
||||
let main={...reference.main,id:actual.main.id};
|
||||
// D188: the Resolve panel is sticky.
|
||||
const actualPanel=findNode(actual.main,'div.gallery-layout')?.children[1];
|
||||
if(actualPanel)main=editNodes(main,'div.gallery-layout',layout=>({...layout,children:layout.children.map((child,index)=>index===1?{...child,position:actualPanel.position}:child)}));
|
||||
let noticeLoss=0;
|
||||
main=editNodes(main,'div.notice',notice=>{
|
||||
const chip=notice.children.find(child=>child.tag==='span.chip');
|
||||
@@ -279,7 +282,7 @@ export function mapConceptReview(reference,actual) {
|
||||
});
|
||||
main=editNodes(main,'select',select=>{
|
||||
const twin=findNode(actual.main,'select');
|
||||
return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select;
|
||||
return select.id==='review-context'?{...select,options:twin.options}:select; // D179: no Leave unresolved option
|
||||
});
|
||||
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
|
||||
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
|
||||
|
||||
@@ -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++; };
|
||||
|
||||
@@ -231,6 +231,8 @@ test('every ordered receipt remains selected through distinct current locations,
|
||||
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
|
||||
// #532: the arrows work over the recorded run from the first moment.
|
||||
await page.keyboard.press('ArrowRight');await expect(viewer).toContainText('2 of 2 loaded');await page.keyboard.press('ArrowLeft');await expect(viewer).toContainText('1 of 2 loaded');
|
||||
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
|
||||
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
|
||||
@@ -468,7 +470,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
||||
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#history-pair History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -975,7 +975,7 @@ async function sendPair(page) {
|
||||
await rail(page).getByRole('button', { name: /^Send prompt pair to ComfyUI: / }).click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Inspect ComfyUI' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Inspect destination' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#pair-1 Inspect destination' });
|
||||
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {seedConcept,CONCEPT_URL} from './concept-inventory.mjs';
|
||||
|
||||
// #530: measured geometry for three deviations from the concept, found in the maintainer's test of the cutover branch.
|
||||
// D182 two-column card tops line up, D183 Reusable tags scrolls in its own window (D185 on touch and at 390px),
|
||||
// D184 System uses the full content width.
|
||||
const workspace='/prompt_manager/workspace';
|
||||
const TAGS=`${workspace}#/tags`;
|
||||
const main=page=>page.locator('#workspace-main');
|
||||
const cards=page=>main(page).locator('.two-col > section.surface');
|
||||
const tagWindow=page=>main(page).locator('.two-col .table-wrap');
|
||||
const rows=page=>tagWindow(page).locator('tbody tr');
|
||||
const loadMore=page=>main(page).getByRole('button',{name:'Load more tags',exact:true});
|
||||
const boxes=locator=>locator.evaluateAll(nodes=>nodes.map(node=>{const {top,bottom,left,right,width}=node.getBoundingClientRect();return {top,bottom,left,right,width};}));
|
||||
const seedTags=async(request,data={})=>expect(await request.post('/__test__/vocabulary/reset',{data})).toBeOK();
|
||||
const nextFrame=page=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
|
||||
test.beforeEach(async({page,browserName})=>{
|
||||
await startCoverage(page,browserName);
|
||||
page.layoutErrors=[];page.on('pageerror',error=>page.layoutErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.layoutErrors).toEqual([]);});
|
||||
|
||||
test('two-column cards start on the same line; the concept still has the 18px offset (D182)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await page.goto(`${workspace}#/system/overview`);
|
||||
await expect(main(page).locator('.summary-metrics')).toBeVisible();
|
||||
const [health,preferences]=await boxes(cards(page));
|
||||
expect(preferences.top).toBe(health.top);
|
||||
await seedTags(request);
|
||||
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
const [tags,categories]=await boxes(cards(page));
|
||||
expect(categories.top).toBe(tags.top);
|
||||
// The deviation is real: the immutable concept keeps its offset.
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#tags`);
|
||||
const concept=await boxes(page.locator('.two-col > section.surface'));
|
||||
expect(concept).toHaveLength(2);
|
||||
expect(Math.round(concept[1].top-concept[0].top)).toBe(18);
|
||||
});
|
||||
|
||||
test('stacked cards keep one 18px gap at 390px (D182)',async({page,request})=>{
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await seedTags(request);
|
||||
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
const [tags,categories]=await boxes(cards(page));
|
||||
expect(Math.round(categories.top-tags.bottom)).toBe(18);
|
||||
});
|
||||
|
||||
test('System sections use the full content width, as Inspect does (D184)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1920,height:1100});
|
||||
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
for(const section of ['overview','appearance','sources','maintenance']) {
|
||||
await page.goto(`${workspace}#/system/${section}`);
|
||||
const layout=main(page).locator('.settings-layout');
|
||||
await expect(layout).toBeVisible();
|
||||
const [[box],[nav]]=await Promise.all([boxes(layout),boxes(main(page).locator(':scope > .section-nav'))]);
|
||||
expect([section,box.left,box.width]).toEqual([section,nav.left,nav.width]);
|
||||
}
|
||||
await page.goto(`${workspace}#/system/overview`);
|
||||
await expect(main(page).locator('.summary-metrics')).toBeVisible();
|
||||
const [[metrics],[columns],[nav]]=await Promise.all([boxes(main(page).locator('.summary-metrics')),boxes(main(page).locator('.settings-layout > .two-col')),boxes(main(page).locator(':scope > .section-nav'))]);
|
||||
expect(metrics.right).toBeCloseTo(nav.right,0);
|
||||
expect(columns.right).toBeCloseTo(nav.right,0);
|
||||
await page.goto(`${workspace}#/inspect`);
|
||||
await expect(main(page).locator('section.surface').first()).toBeVisible();
|
||||
const [inspect]=await boxes(main(page).locator('section.surface').first());
|
||||
expect(inspect.width).toBeCloseTo(nav.width,0);
|
||||
});
|
||||
|
||||
// Every System section and the Tags page: blocks stacked in one column are never closer than the concept's 18px surface
|
||||
// gap. Between a `.two-col` and the surface after it the concept has no gap at all (D182).
|
||||
const SECTIONS=['overview','appearance','sources','integrations','tagging','maintenance','backups','diagnostics'];
|
||||
const stackGaps=host=>host.evaluate(node=>{
|
||||
const blocks=[...node.children].filter(child=>child.matches('.surface,.two-col')&&child.getClientRects().length);
|
||||
return blocks.slice(1).map((block,index)=>[blocks[index].className,block.className,Math.round(block.getBoundingClientRect().top-blocks[index].getBoundingClientRect().bottom)]);
|
||||
});
|
||||
test('every stacked card has one 18px gap, including after a two-column row (D182)',async({page,request})=>{
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});
|
||||
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
for(const section of SECTIONS) {
|
||||
await page.goto('about:blank');await page.goto(`${workspace}#/system/${section}`);
|
||||
const layout=main(page).locator('.settings-layout');
|
||||
await expect(layout.locator(':scope > .surface, :scope > .two-col').first()).toBeVisible();
|
||||
for(const gap of await stackGaps(layout)) expect([width,section,...gap]).toEqual([width,section,gap[0],gap[1],18]);
|
||||
}
|
||||
await seedTags(request);
|
||||
await page.goto('about:blank');await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
for(const gap of await stackGaps(main(page))) expect([width,'tags',...gap]).toEqual([width,'tags',gap[0],gap[1],18]);
|
||||
}
|
||||
// The deviation is real: the immutable concept has no gap after its Backup & export columns.
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#system`);
|
||||
await page.locator('#main .section-nav').getByRole('button',{name:'Backup & export',exact:true}).click();
|
||||
const policy=page.locator('#main .settings-layout > section.surface');await expect(policy).toBeVisible();
|
||||
expect(await policy.evaluate(node=>Math.round(node.getBoundingClientRect().top-node.previousElementSibling.getBoundingClientRect().bottom))).toBe(0);
|
||||
});
|
||||
|
||||
test('a long tag list scrolls in its own window and the page does not grow (D183)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await seedTags(request,{extra_tags:211});
|
||||
await page.goto(TAGS);
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
const size=()=>tagWindow(page).evaluate(node=>({scrollHeight:node.scrollHeight,clientHeight:node.clientHeight,scrollTop:node.scrollTop}));
|
||||
const before=await size();
|
||||
expect(before.scrollHeight).toBeGreaterThan(before.clientHeight);
|
||||
expect(before.clientHeight).toBeGreaterThanOrEqual(280);
|
||||
const [tagCard]=await boxes(cards(page));
|
||||
expect(tagCard.bottom).toBeLessThanOrEqual(1100);
|
||||
await expect(loadMore(page)).toBeInViewport();
|
||||
|
||||
// The header stays at the top of the window while the rows move under it.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=600;});await nextFrame(page);
|
||||
const [[head],[frame]]=await Promise.all([boxes(tagWindow(page).locator('thead th').first()),boxes(tagWindow(page))]);
|
||||
expect(Math.round(head.top)).toBe(Math.round(frame.top));
|
||||
|
||||
// Reaching the end of the window appends one page; the page itself did not scroll.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=node.scrollHeight;});
|
||||
await expect(rows(page)).toHaveCount(200);
|
||||
expect(await page.evaluate(()=>window.scrollY)).toBe(0);
|
||||
// One arrival is one page: the appended rows push the end away, so pages do not chain while more remain.
|
||||
await nextFrame(page);await nextFrame(page);
|
||||
await expect(rows(page)).toHaveCount(200);
|
||||
await expect(loadMore(page)).toBeVisible();
|
||||
|
||||
// Load more works from below the window, with the window not at its end.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
|
||||
await loadMore(page).click();
|
||||
await expect(rows(page)).toHaveCount(211+19);
|
||||
await expect(loadMore(page)).toHaveCount(0);
|
||||
|
||||
// A new sort is a new list: the window returns to its top.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=800;});await nextFrame(page);
|
||||
expect((await size()).scrollTop).toBe(800);
|
||||
await main(page).getByRole('combobox',{name:'Sort tags',exact:true}).selectOption({index:1});
|
||||
await expect.poll(async()=>(await size()).scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
test('a short tag list has no inner scrolling (D183)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await seedTags(request);
|
||||
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
await page.setViewportSize({width:1600,height:2000});await nextFrame(page);
|
||||
const {scrollHeight,clientHeight}=await tagWindow(page).evaluate(node=>({scrollHeight:node.scrollHeight,clientHeight:node.clientHeight}));
|
||||
expect(scrollHeight).toBe(clientHeight);
|
||||
});
|
||||
|
||||
test('at 390px the window fits the screen, actions stay reachable and the page still scrolls (D185)',async({page,request})=>{
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await seedTags(request,{extra_tags:211});
|
||||
await page.goto(TAGS);
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
const [frame]=await boxes(tagWindow(page));
|
||||
expect(frame.bottom-frame.top).toBeLessThan(844);
|
||||
expect(frame.bottom-frame.top).toBeGreaterThanOrEqual(280);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
// The last row's actions can be scrolled to inside the window and fit its width.
|
||||
const last=rows(page).last().getByRole('button',{name:'Rename / merge',exact:true});
|
||||
await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
|
||||
const [button]=await boxes(last);const [after]=await boxes(tagWindow(page));
|
||||
expect(button.right).toBeLessThanOrEqual(after.right);
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
|
||||
await expect(loadMore(page)).toBeAttached();
|
||||
await loadMore(page).scrollIntoViewIfNeeded();await expect(loadMore(page)).toBeInViewport();
|
||||
});
|
||||
|
||||
test('a swipe moves the window inside it and the page outside it (D185)',async({page,request,browserName},testInfo)=>{
|
||||
test.skip(browserName!=='chromium'||testInfo.project.name!=='touch','needs a touch-emulating Chromium');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await seedTags(request,{extra_tags:211});
|
||||
await page.goto(TAGS);
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
await tagWindow(page).evaluate(node=>node.scrollIntoView({block:'start'}));await nextFrame(page);
|
||||
// A real touch sequence: the browser decides what scrolls.
|
||||
const swipe=async(x,y,distance)=>{
|
||||
const client=await page.context().newCDPSession(page);
|
||||
const touch=(type,at)=>client.send('Input.dispatchTouchEvent',{type,touchPoints:type==='touchEnd'?[]:[{x,y:at}]});
|
||||
await touch('touchStart',y);
|
||||
for(let step=1;step<=10;step++) {await touch('touchMove',y+distance*step/10);await nextFrame(page);}
|
||||
await touch('touchEnd');
|
||||
await client.detach();await nextFrame(page);
|
||||
};
|
||||
const [frame]=await boxes(tagWindow(page));
|
||||
const y=Math.round(frame.top+(frame.bottom-frame.top)/2);
|
||||
const pageTop=await page.evaluate(()=>window.scrollY);
|
||||
await swipe(195,y,-150);
|
||||
await expect.poll(()=>tagWindow(page).evaluate(node=>node.scrollTop)).toBeGreaterThan(50);
|
||||
expect(await page.evaluate(()=>window.scrollY)).toBe(pageTop);
|
||||
// From the top of the window a downward swipe chains to the page, so a phone is never trapped in the list.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
|
||||
await swipe(195,y,200);
|
||||
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBeLessThan(pageTop);
|
||||
});
|
||||
@@ -176,7 +176,7 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
|
||||
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
|
||||
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&url.searchParams.get('limit')!=='1')sorts.push(url.searchParams.get('sort'));});// limit=1 is a sidebar count read, not the shelf
|
||||
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
@@ -364,7 +364,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Library destination'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Library destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(sender).toContainText(chosenNegative||'(empty)');
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
|
||||
@@ -716,7 +716,7 @@ test('long prompt is clamped to two lines while copy sends the full text',async(
|
||||
await card.locator('.card-footer .row-actions > button.send').click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Card ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Card destination'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#positive-1 Card destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.cardApplied)).toEqual([{positive:longPrompt}]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ test('send destination offers Ask each time; unsupported options disabled with t
|
||||
.toEqual([['Ask each time', false], ['Selected Prompt Manager node', true], ['Create a Prompt Manager (+/-) node', true]]);
|
||||
await expect(send.locator('select')).toHaveValue('Ask each time');
|
||||
await expect(send.locator('p.small')).toHaveText(
|
||||
'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.');
|
||||
'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.');
|
||||
const renderer = settingOf(page, 'Host compatibility');
|
||||
expect(await renderer.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
|
||||
.toEqual([['Follow host renderer', false], ['Classic', true], ['Nodes 2.0', true]]);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -373,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 }) => {
|
||||
@@ -410,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`);
|
||||
@@ -437,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');
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -93,7 +93,7 @@ test('local file roles send exactly across renderer transitions',async({page,con
|
||||
} finally {await restore(page);}
|
||||
}
|
||||
}
|
||||
// A connected destination input rejects the whole preview: neither role changes, nothing queues or loads.
|
||||
// A connected destination input blocks the whole send: neither role changes, nothing queues or loads.
|
||||
const link=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text');
|
||||
@@ -108,8 +108,11 @@ test('local file roles send exactly across renderer transitions',async({page,con
|
||||
const before=await nodeState(page,id);
|
||||
expect(before.widgets).toMatchObject({text:'Before positive',negative_text:'Before negative'});
|
||||
const rail=await inspect(workspace,FILES[1]);
|
||||
const sender=await preview(workspace,rail);
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
// #535: the linked node is not offered, and the status line says why.
|
||||
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await expect(sender).toContainText(/has its negative prompt linked to another node/);
|
||||
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await nodeState(page,id);
|
||||
expect(rejected.effects).toEqual({queue:0,load:0});
|
||||
|
||||
@@ -117,10 +117,9 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
try {
|
||||
await send.click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
// #535: the linked node is not offered, and the status line says why.
|
||||
await expect(sender).toContainText(/has its negative prompt linked to another node/);
|
||||
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -68,8 +68,14 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);
|
||||
await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
// #535: the window is named by its tab, and one window with one node needs no choosing.
|
||||
const tabTitle=await page.title();
|
||||
await expect(hosts.locator('option').nth(1)).toHaveText(tabTitle);
|
||||
expect(tabTitle).not.toContain('PromptManager');
|
||||
await expect(hosts).not.toHaveValue('');
|
||||
await expect(sender.getByRole('combobox',{name:'Destination node',exact:true})).not.toHaveValue('');
|
||||
await expect(sender.getByRole('status')).toContainText(`in ${tabTitle}. Apply to send it, or preview the changes first.`);
|
||||
await expect(sender.getByRole('combobox',{name:'Destination node',exact:true}).locator('option').nth(1)).toHaveText(new RegExp(`^#${id} `));
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
@@ -86,5 +92,66 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
|
||||
await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')});
|
||||
// #535 D175: Apply without Preview, one click, and a double click sends once.
|
||||
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});
|
||||
const find0=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
|
||||
const positiveBox=nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true});
|
||||
await positiveBox.fill('Typed before one click');
|
||||
await find0.click();
|
||||
await expect(apply).toBeEnabled();
|
||||
await apply.dblclick();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
await expect(positiveBox).toHaveValue(positive);
|
||||
await expect(apply).toBeDisabled();
|
||||
// Edited in ComfyUI after the send: a stray second send would overwrite this.
|
||||
await positiveBox.fill('Typed after the send');
|
||||
await expect(positiveBox).toHaveValue('Typed after the send');
|
||||
// A changed workflow since discovery refuses through one-click Apply, and writes nothing.
|
||||
await find0.click();
|
||||
await expect(apply).toBeEnabled();
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
|
||||
await apply.click();
|
||||
await expect(sender.getByRole('alert')).toContainText(/workflow changed/i);
|
||||
await expect(positiveBox).toHaveValue('Typed after the send');
|
||||
// A field linked after discovery refuses the same way.
|
||||
await find0.click();
|
||||
await expect(apply).toBeEnabled();
|
||||
await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name==='negative_text');
|
||||
if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget);
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text'));
|
||||
},id);
|
||||
await apply.click();
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
await expect(positiveBox).toHaveValue('Typed after the send');
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.disconnectInput(node.inputs.findIndex(input=>input.name==='negative_text'));},id);
|
||||
// Two identical nodes are never selected for you, and read "#<id> <title>".
|
||||
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
|
||||
await find0.click();
|
||||
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
|
||||
await expect(targets.locator('option')).toHaveCount(3);
|
||||
await expect(targets).toHaveValue('');
|
||||
await expect(apply).toBeDisabled();
|
||||
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
|
||||
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
|
||||
// #535: the status line says why a window cannot receive.
|
||||
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
|
||||
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
|
||||
await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name==='text');
|
||||
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
|
||||
},id);
|
||||
await find.click();
|
||||
await expect(sender).toContainText(/has its positive prompt linked to another node/);
|
||||
await expect(preview).toBeDisabled();
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');app.graph.clear();});
|
||||
await find.click();
|
||||
await expect(sender).toContainText('has no Prompt Manager node.');
|
||||
await expect(preview).toBeDisabled();
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();}
|
||||
|
||||
@@ -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;}};
|
||||
}
|
||||
|
||||
@@ -151,3 +151,22 @@ test('callback failure preserves serialization normalized between preview and ap
|
||||
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
|
||||
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
|
||||
});
|
||||
|
||||
test('listTargets reports which roles are linked to an upstream node',async()=>{
|
||||
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
|
||||
const widgets=[{name:'text',value:''},{name:'negative_text',value:''}];
|
||||
const app={graph:{_nodes:[{id:1,type:'PromptManagerPair',widgets,inputs:[{name:'text',link:null},{name:'negative_text',link:4}]},{id:2,type:'PromptManager',widgets,inputs:[]}]}};
|
||||
const [pair,plain]=createPromptAdapter(app,{runtime:{}}).listTargets();
|
||||
assert.deepEqual(pair.linkedRoles,['negative']);assert.deepEqual(plain.linkedRoles,[]);
|
||||
});
|
||||
|
||||
test('the revision moves when the graph is edited and when a load replaces the node, even at the same version',async()=>{
|
||||
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
|
||||
const node=()=>({id:1,type:'PromptManager',widgets:[{name:'text',value:''}],inputs:[]});
|
||||
const app={graph:{_version:9,_nodes:[node()]}};
|
||||
const adapter=createPromptAdapter(app,{runtime:{}});
|
||||
const first=adapter.listTargets()[0].revision;
|
||||
assert.equal(adapter.preview('1',{positive:'x'}).preview.revision,first);
|
||||
app.graph._version=10;const edited=adapter.listTargets()[0].revision;assert.notEqual(edited,first);
|
||||
app.graph={_version:10,_nodes:[node()]};assert.notEqual(adapter.listTargets()[0].revision,edited);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installPromptBridge } from '../../web/js/prompt-bridge-host.js';
|
||||
import { createBridgeClient, createBridgeHost } from '../../web/js/shared/toolkit/prompt-bridge.js';
|
||||
|
||||
function fixture() {
|
||||
const runtime = new EventTarget();
|
||||
@@ -19,7 +20,10 @@ test('host uses the exact deployment prefix and explicit pack label', () => {
|
||||
assert.equal(f.hosts.length, 1);
|
||||
assert.equal(f.hosts[0].config.namespace, '/team/');
|
||||
assert.equal(f.hosts[0].config.origin, 'https://comfy.example');
|
||||
assert.equal(f.hosts[0].config.label, 'PromptManager — My workflow');
|
||||
assert.equal(f.hosts[0].config.label, 'My workflow');
|
||||
f.runtime.document.title = '';
|
||||
assert.equal(f.hosts[0].config.label, 'ComfyUI');
|
||||
f.runtime.document.title = 'My workflow';
|
||||
assert.equal(f.hosts[0].config.adapter, f.adapter);
|
||||
assert.equal(installPromptBridge(app, f.options), controller);
|
||||
controller.dispose(); controller.dispose();
|
||||
@@ -66,3 +70,24 @@ test('deployment namespace rejects external origins and unexpected paths', () =>
|
||||
assert.equal(f.hosts.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('each discovery reports the title the tab has now, through the real Toolkit host', async () => {
|
||||
const f = fixture();
|
||||
f.runtime.location = { origin: 'https://comfy.example', href: 'https://comfy.example/' };
|
||||
f.options.baseUrl = '/prompt_manager/';
|
||||
const listeners = new Set();
|
||||
const channelFactory = () => { const own = new Set(); return {
|
||||
addEventListener(type, listener) { if (type === 'message') { own.add(listener); listeners.add(listener); } }, removeEventListener(type, listener) { listeners.delete(listener); },
|
||||
postMessage(data) { for (const listener of [...listeners]) if (!own.has(listener)) queueMicrotask(() => listener({ data: structuredClone(data), origin: 'https://comfy.example' })); }, close() {},
|
||||
}; };
|
||||
// Not spread: a copy would freeze the title the host must keep reading.
|
||||
f.options.hostFactory = config => { config.channelFactory = channelFactory; return createBridgeHost(config); };
|
||||
f.adapter.listTargets = () => []; f.adapter.preview = () => {}; f.adapter.apply = () => {};
|
||||
f.runtime.document.title = 'ComfyUI';
|
||||
const controller = installPromptBridge({}, f.options);
|
||||
const client = createBridgeClient({ namespace: '/', origin: 'https://comfy.example', channelFactory });
|
||||
f.runtime.document.title = '*Unsaved Workflow - ComfyUI';
|
||||
try {
|
||||
assert.equal((await client.discover({ waitMs: 100 }))[0].label, '*Unsaved Workflow - ComfyUI');
|
||||
} finally { client.dispose(); controller.dispose(); }
|
||||
});
|
||||
|
||||
@@ -11,18 +11,17 @@ function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
|
||||
}
|
||||
test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
|
||||
const {createSendSession} = await source; const s=setup(createSendSession);
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.preview(); assert.equal(s.calls.length,0);
|
||||
s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
|
||||
await s.session.apply();assert.equal(s.calls.length,0);
|
||||
s.session.selectTarget('one');
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
|
||||
await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
|
||||
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
|
||||
});
|
||||
test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
|
||||
test('pair roles filter incompatible nodes and select the sole destination', async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
|
||||
await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
assert.equal(s.session.state.targetId,'pair');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
|
||||
});
|
||||
test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
|
||||
@@ -44,20 +43,127 @@ test('only matching explicit acknowledgements count as success',async()=>{
|
||||
}
|
||||
});
|
||||
test('discovery, empty results and stale preview errors remain actionable',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No ComfyUI window answered/i);s.session.dispose();
|
||||
const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
|
||||
const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
|
||||
});
|
||||
test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
|
||||
const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
|
||||
const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
});
|
||||
test('pending apply explains closing cannot undo the sent update',async()=>{
|
||||
const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
|
||||
assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
|
||||
});
|
||||
const window1=(targets,label='Flow - ComfyUI',hostId='a')=>({hostId,label,targets});
|
||||
const pm=(id,roles=['positive'],extra={})=>({id,label:`Node ${id}`,roles,...extra});
|
||||
async function discovered(createSendSession,found,roles={positive:'new'}) {
|
||||
const s=setup(createSendSession,{discover:async()=>found},roles);await s.session.discover();return s;
|
||||
}
|
||||
test('one window with one eligible node is selected so Preview is available at once',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')])]);
|
||||
assert.equal(s.session.state.hostId,'a');assert.equal(s.session.state.targetId,'1');
|
||||
assert.match(s.session.state.message,/#1 Node 1 in Flow - ComfyUI.*Apply/);
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
|
||||
});
|
||||
test('several windows are left to choose, and choosing one with a single node selects that node',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'A','a'),window1([pm('2'),pm('3')],'B','b')]);
|
||||
assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a ComfyUI window/);
|
||||
s.session.selectHost('a');assert.equal(s.session.state.targetId,'1');
|
||||
s.session.selectHost('b');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a destination node in B/);
|
||||
s.session.selectHost('');assert.match(s.session.state.message,/Choose a ComfyUI window/);
|
||||
s.session.selectHost('b');s.session.selectTarget('2');s.session.selectTarget('');assert.match(s.session.state.message,/Choose a destination node/);s.session.dispose();
|
||||
});
|
||||
test('a window that cannot receive says why, naming the window and the node',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
let s=await discovered(createSendSession,[window1([])]);
|
||||
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([]),window1([],'Other','b')]);
|
||||
assert.match(s.session.state.message,/None of the 2 ComfyUI windows has a Prompt Manager node/);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']})])]);
|
||||
assert.match(s.session.state.message,/Node 1 in Flow - ComfyUI has its positive prompt linked/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1')])],{positive:'p',negative:'n'});
|
||||
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node that accepts a positive and negative prompt.*\(\+\/-\)/);s.session.dispose();
|
||||
});
|
||||
test('a linked node is not offered beside an eligible one, and an unlinked role still is',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
let s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']}),pm('2')])]);
|
||||
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['2']);assert.equal(s.session.state.targetId,'2');s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1',['positive','negative'],{linkedRoles:['negative']})])]);
|
||||
assert.equal(s.session.state.targetId,'1');s.session.dispose();
|
||||
});
|
||||
test('an auto-selected destination says how many other nodes cannot receive, and the review names the destination',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
const s=await discovered(createSendSession,[window1([pm('2')],'A','a'),window1([pm('1',['positive'],{linkedRoles:['positive']})],'B','b')]);
|
||||
assert.equal(s.session.state.targetId,'2');assert.match(s.session.state.message,/1 other Prompt Manager node cannot receive/);
|
||||
await s.session.preview();s.session.dispose();
|
||||
const named=setup(createSendSession,{discover:async()=>[window1([pm('one')])],preview:async(_,id)=>({ticket:'t',target:{id,label:'Live title'},fields:[{role:'positive',before:'',after:'new'}]})});
|
||||
await named.session.discover();await named.session.preview();assert.match(named.session.state.message,/Sending to #one Live title in Flow - ComfyUI/);named.session.dispose();
|
||||
});
|
||||
test('window order and numbering do not depend on which tab answered first',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'T','b'),window1([pm('2')],'T','a')]);
|
||||
assert.deepEqual(s.session.state.hosts.map(host=>[host.hostId,host.label]),[['a','T'],['b','T (window 2)']]);s.session.dispose();
|
||||
});
|
||||
const applied=calls=>calls.filter(call=>call[0]==='apply').length;
|
||||
test('Apply without Preview fetches the ticket and applies in one click',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
|
||||
await s.session.discover();await s.session.apply();
|
||||
assert.equal(s.session.state.phase,'success');assert.deepEqual(s.calls.map(call=>call[0]),['preview','apply']);s.session.dispose();
|
||||
});
|
||||
test('a double click applies once, during the send and after it',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
|
||||
await s.session.discover();const first=s.session.apply();const second=s.session.apply();await Promise.all([first,second]);
|
||||
assert.equal(applied(s.calls),1);await s.session.apply();assert.equal(applied(s.calls),1);s.session.dispose();
|
||||
});
|
||||
test('Apply after a preview uses its ticket and does not preview again',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
|
||||
await s.session.discover();await s.session.preview();await s.session.apply();
|
||||
assert.deepEqual(s.calls.map(call=>call[0]),['preview','apply']);assert.equal(s.session.state.phase,'success');s.session.dispose();
|
||||
});
|
||||
test('Apply needs a destination',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession);
|
||||
await s.session.discover();await s.session.apply();assert.equal(s.calls.length,0);s.session.dispose();
|
||||
});
|
||||
test('a refusal at the fetched preview says so plainly and writes nothing; Apply can try again',async()=>{
|
||||
const {createSendSession}=await source;let refuse=true;
|
||||
const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])],preview:async(_,id)=>{if(refuse)throw Error('positive is connected to an upstream input.');return{ticket:'t',target:{id},fields:[{role:'positive',before:'',after:'new'}]};}});
|
||||
await s.session.discover();await s.session.apply();
|
||||
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/connected/);assert.doesNotMatch(s.session.state.message,/may have applied/);
|
||||
refuse=false;await s.session.apply();assert.equal(s.session.state.phase,'success');s.session.dispose();
|
||||
});
|
||||
test('a host refusal at apply is a refusal; only a lost reply is an unknown outcome',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])],apply:async()=>{throw Error('Workflow or prompt changed. Preview again.');}});
|
||||
await s.session.discover();await s.session.apply();assert.match(s.session.state.message,/Workflow or prompt changed/);assert.doesNotMatch(s.session.state.message,/may have applied/);s.session.dispose();
|
||||
const lost=setup(createSendSession,{discover:async()=>[window1([pm('one')])],apply:async()=>{throw Error('Bridge request timed out');}});
|
||||
await lost.session.discover();await lost.session.apply();assert.match(lost.session.state.message,/may have applied/);
|
||||
await lost.session.apply();assert.equal(lost.calls.filter(call=>call[0]==='preview').length,1);
|
||||
lost.session.selectTarget('one');await lost.session.apply();assert.equal(lost.calls.filter(call=>call[0]==='preview').length,2);lost.session.dispose();
|
||||
});
|
||||
test('a workflow changed since discovery refuses before anything is written',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
for(const op of ['apply','preview']){
|
||||
const s=setup(createSendSession,{discover:async()=>[window1([pm('one',['positive'],{revision:'1:4'})])],preview:async(_,id)=>({ticket:'t',revision:'1:5',target:{id},fields:[{role:'positive',before:'',after:'new'}]})});
|
||||
await s.session.discover();await s.session[op]();
|
||||
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/workflow changed/i);assert.equal(applied(s.calls),0);s.session.dispose();
|
||||
}
|
||||
const stale=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});await stale.session.discover();await stale.session.apply();assert.equal(stale.session.state.phase,'success');stale.session.dispose();
|
||||
});
|
||||
test('two or more eligible nodes are never auto-selected, and each option shows id and title',async()=>{
|
||||
const {createSendSession}=await source;const twin=id=>({id,label:'Prompt Manager (+/-)',roles:['positive']});
|
||||
const s=await discovered(createSendSession,[window1([twin('72'),twin('73')])]);
|
||||
assert.equal(s.session.state.targetId,'');assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.label),['#72 Prompt Manager (+/-)','#73 Prompt Manager (+/-)']);
|
||||
await s.session.apply();assert.equal(s.calls.length,0);s.session.dispose();
|
||||
const one=await discovered(createSendSession,[window1([twin('72')])]);assert.equal(one.session.state.hosts[0].targets[0].label,'#72 Prompt Manager (+/-)');assert.equal(one.session.state.targetId,'72');one.session.dispose();
|
||||
});
|
||||
test('same-named windows and nodes stay distinguishable',async()=>{
|
||||
const {createSendSession}=await source;const twin=id=>({id,label:'Same',roles:['positive']});
|
||||
const s=await discovered(createSendSession,[window1([twin('7'),twin('8')],'T','a'),window1([pm('1')],'T','b')]);
|
||||
assert.deepEqual(s.session.state.hosts.map(host=>host.label),['T','T (window 2)']);
|
||||
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.label),['#7 Same','#8 Same']);s.session.dispose();
|
||||
});
|
||||
function fakeDOM() {
|
||||
class Node extends EventTarget {
|
||||
constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
|
||||
@@ -89,7 +195,7 @@ test('native sender dialog uses prefixed namespace, full safe text, explicit cho
|
||||
assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
|
||||
const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
|
||||
const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
|
||||
assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
|
||||
assert.equal(host.value,'host');assert.equal(target.value,'');target.value='one';target.dispatchEvent(new Event('change'));
|
||||
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
|
||||
assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='<unsafe>new</unsafe>'));
|
||||
const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -123,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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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], "")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
|
||||
@@ -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())
|
||||
@@ -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",)])
|
||||
@@ -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}",
|
||||
),
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
+36
-24
@@ -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": {
|
||||
@@ -216,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,
|
||||
@@ -288,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,
|
||||
@@ -317,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,
|
||||
@@ -355,7 +355,7 @@
|
||||
},
|
||||
"workspace/images.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 89,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
@@ -368,7 +368,7 @@
|
||||
"workspace/inspect.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 93,
|
||||
"functions": 94,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/inspection-state.mjs": {
|
||||
@@ -379,7 +379,7 @@
|
||||
},
|
||||
"workspace/inspector-collections.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -397,7 +397,7 @@
|
||||
},
|
||||
"workspace/library-editor.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"branches": 94,
|
||||
"functions": 92,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -504,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,
|
||||
@@ -551,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
|
||||
},
|
||||
@@ -619,7 +625,7 @@
|
||||
},
|
||||
"workspace/system-sources.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 88,
|
||||
"branches": 89,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -712,5 +718,11 @@
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/sticky-chrome.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 60,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ const compositionNames = ['prepend_text','append_text'];
|
||||
function nodes(graph) { return graph?._nodes || []; }
|
||||
function target(node) { return {id:String(node.id),label:String(node.title || node.type),type:node.type}; }
|
||||
function connected(node,name) { return Boolean(node.inputs?.some(input => input.name === name && input.link != null)); }
|
||||
function linkedRoles(node) { return Object.entries(schemas.get(node.type)).filter(([,name]) => connected(node,name)).map(([role]) => role); }
|
||||
function links(node) { return JSON.stringify((node.inputs || []).map(input => [input.name,input.link ?? null])); }
|
||||
function capture(node,name) {
|
||||
const widget = node.widgets?.find(item => item.name === name);
|
||||
@@ -57,9 +58,16 @@ function renderer(runtime) {
|
||||
|
||||
export function createPromptAdapter(app, {runtime = globalThis} = {}) {
|
||||
const snapshots = new WeakMap();
|
||||
// Loading a workflow makes new node objects but can land on the same graph version, so a revision needs both.
|
||||
const nodeSerials = new WeakMap();
|
||||
let lastSerial = 0;
|
||||
function revision(node) {
|
||||
if (!nodeSerials.has(node)) nodeSerials.set(node,++lastSerial);
|
||||
return `${nodeSerials.get(node)}:${app.graph?._version}`;
|
||||
}
|
||||
function listTargets() {
|
||||
const drawn = renderer(runtime);
|
||||
return nodes(app.graph).filter(node => schemas.has(node.type)).map(node => ({...target(node),roles:Object.keys(schemas.get(node.type)),...drawn}));
|
||||
return nodes(app.graph).filter(node => schemas.has(node.type)).map(node => ({...target(node),roles:Object.keys(schemas.get(node.type)),linkedRoles:linkedRoles(node),revision:revision(node),...drawn}));
|
||||
}
|
||||
function preview(targetId,rawRoles) {
|
||||
const roles = validateRoles(rawRoles);
|
||||
@@ -77,7 +85,7 @@ export function createPromptAdapter(app, {runtime = globalThis} = {}) {
|
||||
});
|
||||
const snapshot = Object.freeze({});
|
||||
snapshots.set(snapshot,{graph,node,type:node.type,id:String(node.id),version:graph._version,widgets:node.widgets,widgetOrder:[...node.widgets],fields,links:links(node),requested});
|
||||
return {preview:{target:target(node),fields:requested.map(({role,after,field}) => ({role,before:String(field.value ?? ''),after})),composition:composition(node,fields,roles)},snapshot};
|
||||
return {preview:{target:target(node),revision:revision(node),fields:requested.map(({role,after,field}) => ({role,before:String(field.value ?? ''),after})),composition:composition(node,fields,roles)},snapshot};
|
||||
}
|
||||
function apply(snapshot) {
|
||||
const state = snapshots.get(snapshot);
|
||||
|
||||
@@ -16,7 +16,8 @@ export function installPromptBridge(app, { baseUrl, runtime = globalThis,
|
||||
if (host || disposed) return;
|
||||
try {
|
||||
host = hostFactory({ namespace, origin: runtime.location.origin,
|
||||
label: `PromptManager — ${runtime.document.title || 'ComfyUI'}`, adapter: adapterFactory(app) });
|
||||
// A getter: the Toolkit reads this on every discovery, and the tab title follows the open workflow.
|
||||
get label() { return runtime.document.title || 'ComfyUI'; }, adapter: adapterFactory(app) });
|
||||
} catch (error) {
|
||||
runtime.console.warn('[PromptManager] Prompt sending is unavailable:', error.message);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+39
-21
@@ -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';
|
||||
@@ -83,6 +84,7 @@ 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 });
|
||||
@@ -151,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]);
|
||||
}
|
||||
}
|
||||
@@ -161,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();
|
||||
@@ -497,34 +511,38 @@ function mountWorkspace() {
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,51 +1,117 @@
|
||||
import {createBridgeClient, validateRoles} from '../shared/toolkit/prompt-bridge.js';
|
||||
import {createButton, createField, createScope, createSelect} from '../shared/toolkit/core.js';
|
||||
|
||||
// The same wait as System's renderer line, so a busy tab is not reported as missing.
|
||||
const DISCOVERY_WAIT_MS = 300;
|
||||
const roleNames = roles => Object.keys(roles).join(' and ');
|
||||
const roleOf = target => Array.isArray(target.roles) ? target.roles : [];
|
||||
const accepts = (target,roles) => typeof target.id==='string' && Object.keys(roles).every(role=>roleOf(target).includes(role));
|
||||
const linkedTo = (target,roles) => Object.keys(roles).filter(role=>Array.isArray(target.linkedRoles) && target.linkedRoles.includes(role));
|
||||
|
||||
const nodeLabel = target => `#${target.id} ${target.label}`;
|
||||
// A lost reply leaves the outcome unknown; anything else the host said is a refusal, and nothing was written.
|
||||
const LOST_REPLY = /timed out|aborted|disposed/i;
|
||||
|
||||
/** Why nothing in the discovered windows can receive these roles, most specific first (D173). */
|
||||
function unavailableMessage(found,roles) {
|
||||
if (!found.length) return 'No ComfyUI window answered in this browser at this address. Open ComfyUI here, then find windows again.';
|
||||
for (const host of found) for (const target of host.targets) {
|
||||
const linked = accepts(target,roles) ? linkedTo(target,roles) : [];
|
||||
if (linked.length) return `${nodeLabel(target)} in ${host.label} has its ${linked.join(' and ')} prompt linked to another node, so it cannot receive text. Disconnect the link, then find windows again.`;
|
||||
}
|
||||
const where = found.length===1 ? found[0].label : `None of the ${found.length} ComfyUI windows`;
|
||||
const verb = found.length===1 ? 'has no' : 'has a';
|
||||
if (found.some(host=>host.targets.length)) {
|
||||
const need = Object.hasOwn(roles,'negative') ? ' Add a Prompt Manager (+/-) node, then find windows again.' : ' Add one, then find windows again.';
|
||||
return `${where} ${verb} Prompt Manager node that accepts a ${roleNames(roles)} prompt.${need}`;
|
||||
}
|
||||
return `${where} ${verb} Prompt Manager node. Add one to the workflow, then find windows again.`;
|
||||
}
|
||||
/** Keeps same-named windows apart: tabs on one workflow. */
|
||||
function distinctLabels(items,suffix) {
|
||||
const seen = new Map();
|
||||
const totals = new Map();
|
||||
for (const item of items) totals.set(item.label,(totals.get(item.label) || 0)+1);
|
||||
return items.map(item => {
|
||||
const count = (seen.get(item.label) || 0)+1;seen.set(item.label,count);
|
||||
return totals.get(item.label)>1 ? {...item,label:suffix(item,count)} : item;
|
||||
});
|
||||
}
|
||||
|
||||
export function createSendSession({client, roles:rawRoles, onChange}) {
|
||||
const roles = Object.freeze(validateRoles(rawRoles));
|
||||
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,message:''};
|
||||
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,outcomeUnknown:false,message:''};
|
||||
let operation, generation = 0, disposed = false;
|
||||
const emit = patch => { if (!disposed) { state = Object.freeze({...state,...patch}); onChange(state); } };
|
||||
function invalidate() { generation++; operation?.abort(); operation = null; }
|
||||
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,message:''}); return {signal:operation.signal,version:generation}; }
|
||||
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,outcomeUnknown:false,message:''}); return {signal:operation.signal,version:generation}; }
|
||||
const current = request => !disposed && generation === request.version;
|
||||
const selected = () => state.hosts.find(host=>host.hostId===state.hostId)?.targets.find(target=>target.id===state.targetId);
|
||||
async function fetchPreview(request) {
|
||||
const preview=await client.preview(state.hostId,state.targetId,roles,{signal:request.signal});
|
||||
if (!current(request)) return null;
|
||||
const fields=preview?.fields;
|
||||
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
|
||||
// Undefined on a tab still running older code, which has nothing to compare.
|
||||
const found=selected()?.revision;
|
||||
if(found!==undefined && preview.revision!==found) throw Error('The workflow changed since this window was found. Nothing was sent. Find windows again.');
|
||||
return preview;
|
||||
}
|
||||
function hostChoice(host) {
|
||||
if (host.targets.length>1) return {hostId:host.hostId,targetId:'',message:`Choose a destination node in ${host.label}.`};
|
||||
const [target] = host.targets;
|
||||
return {hostId:host.hostId,targetId:target.id,message:`Sending to ${target.label} in ${host.label}. Apply to send it, or preview the changes first.`};
|
||||
}
|
||||
return {
|
||||
get state() { return state; },
|
||||
async discover() {
|
||||
if (disposed) return;
|
||||
const request = begin('discovering'); emit({hosts:[],hostId:'',targetId:'',message:'Looking for ComfyUI windows…'});
|
||||
try {
|
||||
const found = await client.discover({signal:request.signal});
|
||||
const found = (await client.discover({signal:request.signal,waitMs:DISCOVERY_WAIT_MS})).map(host=>({...host,targets:host.targets.filter(target=>target && typeof target==='object')}));
|
||||
if (!current(request)) return;
|
||||
const hosts = found.map(host=>({...host,targets:host.targets.filter(target=>typeof target.id==='string' && Array.isArray(target.roles) && Object.keys(roles).every(role=>target.roles.includes(role)))})).filter(host=>host.targets.length);
|
||||
emit({phase:'select',hosts,message:hosts.length ? 'Choose a ComfyUI window and destination node.' : 'No compatible ComfyUI windows found. Open a workflow with a compatible Prompt Manager node, then find windows again.'});
|
||||
const hosts = distinctLabels(found.toSorted((a,b)=>a.hostId<b.hostId?-1:1).map(host=>({...host,targets:host.targets.filter(target=>accepts(target,roles) && !linkedTo(target,roles).length).map(target=>({...target,label:nodeLabel(target)}))})).filter(host=>host.targets.length),(host,count)=>count>1 ? `${host.label} (window ${count})` : host.label);
|
||||
// Nodes left out are counted so an auto-selected destination never hides the one the user meant.
|
||||
const skipped = found.reduce((total,host)=>total+host.targets.length,0)-hosts.reduce((total,host)=>total+host.targets.length,0);
|
||||
const note = skipped ? ` ${skipped} other Prompt Manager ${skipped===1?'node':'nodes'} cannot receive this prompt.` : '';
|
||||
if (!hosts.length) emit({phase:'select',hosts,message:unavailableMessage(found,roles)});
|
||||
else if (hosts.length>1) emit({phase:'select',hosts,message:`Choose a ComfyUI window.${note}`});
|
||||
else { const choice=hostChoice(hosts[0]); emit({phase:'select',hosts,...choice,message:choice.message+note}); }
|
||||
} catch (error) { if (current(request)) emit({phase:'error',message:error.message}); }
|
||||
},
|
||||
selectHost(hostId) { if (disposed) return; invalidate(); emit({phase:'select',hostId:state.hosts.some(host=>host.hostId===hostId)?hostId:'',targetId:'',preview:null,message:'Choose a destination node, then preview changes.'}); },
|
||||
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); emit({phase:'select',targetId:host?.targets.some(target=>target.id===targetId)?targetId:'',preview:null,message:'Preview changes before applying.'}); },
|
||||
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,outcomeUnknown:false,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
|
||||
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); const chosen=host?.targets.some(target=>target.id===targetId); emit({phase:'select',outcomeUnknown:false,targetId:chosen?targetId:'',preview:null,message:chosen?'Apply to send it, or preview the changes first.':'Choose a destination node, then preview changes.'}); },
|
||||
async preview() {
|
||||
if (disposed || !selected() || ['previewing','applying'].includes(state.phase)) return;
|
||||
const request=begin('previewing');
|
||||
try {
|
||||
const preview=await client.preview(state.hostId,state.targetId,roles,{signal:request.signal});
|
||||
if (!current(request)) return;
|
||||
const fields=preview?.fields;
|
||||
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
|
||||
emit({phase:'ready',preview,message:'Review the complete changes, then apply.'});
|
||||
const preview=await fetchPreview(request);
|
||||
if (!preview) return;
|
||||
const windowLabel=state.hosts.find(host=>host.hostId===state.hostId)?.label;
|
||||
emit({phase:'ready',preview,message:`Sending to ${nodeLabel({id:preview.target.id,label:preview.target.label ?? ''})} in ${windowLabel}. Review the complete changes, then apply.`});
|
||||
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:error.message}); }
|
||||
},
|
||||
async apply() {
|
||||
if(disposed || state.phase!=='ready' || !state.preview) return;
|
||||
const {ticket}=state.preview; const targetId=state.targetId; const hostId=state.hostId;
|
||||
// Success stays closed: the second click of a double click must not send again.
|
||||
if(disposed || !selected() || !['select','ready','error'].includes(state.phase) || state.outcomeUnknown) return;
|
||||
const held=state.phase==='ready' ? state.preview : null;
|
||||
const targetId=state.targetId; const hostId=state.hostId;
|
||||
const request=begin('applying');
|
||||
emit({message:'Waiting for ComfyUI confirmation. Closing this dialog does not undo a sent update.'});
|
||||
let posted=false;
|
||||
try {
|
||||
const acknowledgement=await client.apply(hostId,ticket,{signal:request.signal});
|
||||
const preview=held ?? await fetchPreview(request);
|
||||
if(!preview || !current(request)) return;
|
||||
posted=true;
|
||||
const acknowledgement=await client.apply(hostId,preview.ticket,{signal:request.signal});
|
||||
if(!current(request)) return;
|
||||
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
|
||||
emit({phase:'success',message:'Sent to ComfyUI.'});
|
||||
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:`No confirmation received. The update may have applied. Check ComfyUI, then preview again. ${error.message}`}); }
|
||||
} catch(error) {
|
||||
if(!current(request)) return;
|
||||
const unknown=posted && (LOST_REPLY.test(error.message) || /unexpected acknowledgement/.test(error.message));
|
||||
emit({phase:'error',preview:null,outcomeUnknown:unknown,message:unknown ? `No confirmation received. The update may have applied. Check ComfyUI, then preview again. ${error.message}` : error.message});
|
||||
}
|
||||
},
|
||||
dispose() { if(disposed) return; disposed=true; invalidate(); client.dispose(); },
|
||||
};
|
||||
@@ -82,7 +148,7 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
|
||||
const header=element('header',undefined,'prompt-sender-header');
|
||||
const title=element('h2','Send to ComfyUI');const dismiss=createButton('Close');
|
||||
header.append(title,dismiss);
|
||||
const description=element('p','Choose where to send this prompt. Nothing changes until you approve the preview.');
|
||||
const description=element('p','Choose where to send this prompt. Apply writes it to that node. Preview changes shows the result first, if you want to check it.');
|
||||
const status=element('p','', 'prompt-sender-status');status.setAttribute('role','status');status.setAttribute('aria-live','polite');
|
||||
const host=createSelect([['','Choose a ComfyUI window']], '');
|
||||
const target=createSelect([['','Choose a destination node']], '');
|
||||
@@ -123,7 +189,7 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
|
||||
const selected=state.hosts.find(item=>item.hostId===state.hostId);
|
||||
options(target,'Choose a destination node',(selected?.targets || []).map(item=>[item.id,item.label]),state.targetId);
|
||||
host.disabled=busy;target.disabled=busy || !selected;
|
||||
find.disabled=busy;inspect.disabled=busy || !state.targetId;apply.disabled=state.phase!=='ready';
|
||||
find.disabled=busy;inspect.disabled=busy || !state.targetId;apply.disabled=busy || !state.targetId || state.phase==='success' || state.outcomeUnknown;
|
||||
preview.replaceChildren();
|
||||
if(state.preview) {
|
||||
preview.append(element('h3','Review changes'));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -95,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([
|
||||
@@ -158,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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* System › Integrations (#249 System S10 Task 27): the concept's `renderSystem` "integrations" body (app.js 682–684).
|
||||
*
|
||||
* - "ComfyUI": Default send destination, Host compatibility and Return to canvas after send. D106 (Q8): the send
|
||||
* bridge always asks for the window and node, and the nodes always follow ComfyUI's own renderer, so only "Ask each
|
||||
* bridge asks for the window and node when there is a choice, and the nodes always follow ComfyUI's own renderer, so only "Ask each
|
||||
* time" and "Follow host renderer" can be chosen; the other options and the return toggle are disabled, with the
|
||||
* reason under the row, and nothing is stored for them. Host compatibility also says which renderer the open
|
||||
* ComfyUI windows use, as the send bridge reports it (`renderer` on each Prompt Manager node it lists).
|
||||
@@ -42,7 +42,7 @@ export const LORA_KEYS = Object.freeze(['lora_enabled', 'lora_trigger_words_enab
|
||||
|
||||
/** D106: why the options the bridge cannot honour are disabled. */
|
||||
export const REASONS = Object.freeze({
|
||||
send: 'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.',
|
||||
send: 'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.',
|
||||
renderer: 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.',
|
||||
returnCanvas: 'Not available: sending applies the text in ComfyUI and keeps this page open.',
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user