Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a43755ae | ||
|
|
2c7e84dedd | ||
|
|
cc65d15d14 | ||
|
|
d88cf9e7c6 | ||
|
|
df70b5dfd6 | ||
|
|
7e1847955b | ||
|
|
ecb6b09813 | ||
|
|
cdb791d4a9 | ||
|
|
f924d7195d | ||
|
|
3789d85e58 | ||
|
|
fb6452934f | ||
|
|
395dc74cbe | ||
|
|
2f061904a2 | ||
|
|
f80e67c812 | ||
|
|
ab6fee3a82 | ||
|
|
f53a43152d | ||
|
|
fe63f01214 | ||
|
|
aab0fedc10 | ||
|
|
085e45e475 | ||
|
|
89572e03c6 | ||
|
|
ae3e191990 | ||
|
|
cd284432f5 |
@@ -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
|
||||
|
||||
@@ -763,7 +763,7 @@ filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
|
||||
content without the cover, then "File & provenance" (the honest file facts, the
|
||||
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
|
||||
`Prompt evidence` line, Download original and, for a listed file, D38's
|
||||
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
|
||||
two downloads), then the pair send and Prompt history buttons. D22 words an empty
|
||||
positive explicitly and makes confirmed prompt text focusable.
|
||||
@@ -1402,6 +1402,13 @@ seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
|
||||
Tag) and asserts that every row reserves the tallest footer and that their own footer
|
||||
is shorter than the row it reserves.
|
||||
|
||||
### Overview counts (#547)
|
||||
|
||||
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
|
||||
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
|
||||
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
|
||||
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
|
||||
|
||||
### Images page controls and recorded deviations (#249 PR C)
|
||||
|
||||
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
|
||||
@@ -1566,20 +1573,27 @@ Recorded deviations on this page (spec §8):
|
||||
|
||||
| Result | Toast |
|
||||
|---|---|
|
||||
| Success | "Opened the folder on this computer." |
|
||||
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
|
||||
| 501 `unavailable` | "No file manager is available on this computer." |
|
||||
| Success | "Opened the folder on the computer running ComfyUI." |
|
||||
| 403 `not-same-origin` | "The folder was not opened: this page's address does not match the server's." |
|
||||
| 501 `unavailable` | "No file manager is available on the computer running ComfyUI." |
|
||||
| 429 `busy` | "The folder is already opening." |
|
||||
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
|
||||
|
||||
The route is local-only. It answers 403 `not-local` unless all of these hold: no
|
||||
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
|
||||
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
|
||||
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
|
||||
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
|
||||
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
|
||||
`null`, another port or another scheme is refused; browsers send `Origin` on every
|
||||
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
|
||||
**D176 (#533, beyond the concept):** the viewer is a modal, so `showWorkspaceToast`
|
||||
moves `#workspace-toast` into the topmost open modal (by hit test when several are
|
||||
open), keeping its fixed position, and back when that modal closes. A modal opened
|
||||
after a toast covers it until the next toast. Otherwise every toast raised from a modal is
|
||||
covered and inert while `toHaveText` still passes: assert `toBeVisible` and the
|
||||
hit test as `full-inspector.spec.mjs` does.
|
||||
|
||||
The route opens the folder on the computer running ComfyUI wherever the browser is,
|
||||
through a proxy too (maintainer decision, #533, amending the original local-only
|
||||
rule; accepted risk: anyone who can reach the server can open file windows on it).
|
||||
It answers 403 `not-same-origin` unless `Origin` is present and matches: its host
|
||||
and port against `X-Forwarded-Host` when sent, else `Host`, with the scheme's default
|
||||
port filled in; its scheme against `X-Forwarded-Proto` when sent, else the socket's
|
||||
(an https page on an http socket passes, TLS having ended at a proxy); and, when
|
||||
sent, `Sec-Fetch-Site: same-origin`. A body that is not
|
||||
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
|
||||
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
|
||||
relative path; absolute, drive and `..` paths are 400), the scope goes through
|
||||
@@ -1596,7 +1610,9 @@ Recorded deviations on this page (spec §8):
|
||||
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
|
||||
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
|
||||
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
|
||||
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
|
||||
`xdg-open` on the folder, or answers 501 without `DISPLAY` and `WAYLAND_DISPLAY`.
|
||||
`open` and `xdg-open` get `OPENER_WAIT` (1 s): a nonzero exit is the 500 above, and
|
||||
one still running counts as started and goes to the reaper (#533). Those openers are argv lists resolved only on a fixed
|
||||
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
|
||||
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
|
||||
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
|
||||
@@ -1604,12 +1620,11 @@ Recorded deviations on this page (spec §8):
|
||||
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
|
||||
closed disclosures as hidden, so the button needs no mapping there.
|
||||
|
||||
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
|
||||
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
|
||||
from a local client, and a non-browser client behind it can forge a matching
|
||||
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
|
||||
screen, at most once a second, and learn whether that file exists (which the
|
||||
listing routes already tell it). Operators who expose ComfyUI that way accept this.
|
||||
Limits of that check: a proxy that rewrites `Host` and sends no `X-Forwarded-Host` is
|
||||
refused with that toast until it sends one. A non-browser client can forge every
|
||||
header. The check stops a plain cross-site POST, not every other site: a DNS-rebinding
|
||||
page has a matching `Host` and `Origin`, so it can open a folder of a listed file, at
|
||||
most once a second. The path check confines it, one check ahead of every opener.
|
||||
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
|
||||
category when set, otherwise the linked positive prompt's, and its recipe's own tags
|
||||
when it has any, otherwise the positive's; each field falls back on its own and the
|
||||
@@ -2489,7 +2504,28 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
|
||||
(`_collect_media_files`), the legacy auto-tag listing (`_collect_scan_files`) and the
|
||||
delete review (`trash-folder`), and the image monitor, which tests the folders below the
|
||||
watched folder holding the new file (a source named like a trash folder is still watched). Tests: `tests/test_media_folders.py`,
|
||||
`tests/test_image_removal_gaps.py`.
|
||||
`tests/test_image_removal_gaps.py`. Media routes answer not found for a path in such a
|
||||
folder, resolved first so a link or another spelling cannot pass (#543):
|
||||
`_media_access_error` is the one gate (by-id, inspect, reveal, link, rescope as
|
||||
`file-missing`, a prompt's media listing); `/images/serve` and the autotag target ask
|
||||
`in_trash_below_roots` too, `media_target` refuses by name (annotations, collections,
|
||||
whose capture store denies links and aliases), and previews are neither made nor served, the fallback past the preview gate's cap
|
||||
included (`_quick_source`). A History output in one answers `missing`, checked at the
|
||||
candidate and again at the open (a preview can be a link). The legacy duplicate delete
|
||||
removes exactly the folder's real path plus the name (a link itself, never its target)
|
||||
and refuses if either spelling is in a trash folder; `file_problem` refuses too. The delete
|
||||
and Return flows never use this gate. Windows spellings (trailing dots and spaces, case,
|
||||
8.3) run for real on the Windows job in `tests/test_trash_gate_routes.py`.
|
||||
- **An output folder that is a mount point** (#543): the mount's own `.Trash-$uid` and
|
||||
`.Trash/$uid` are allowed only for a root that is exactly that mount, with the spec's
|
||||
checks intact. A root above the mount, a mount nested under the root, a source that is or
|
||||
sits in the trash folder, and a link in place of the trash folder stay refused. Tests:
|
||||
`tests/test_trash_mount_root.py`, on a fake mount table in the suite and, with
|
||||
`PM_TRASH_MOUNT=<real mount>`, in portability.yml's `native-trash-linux` job (loop-mounted
|
||||
ext4). Locally, without root: `unshare -Urm` over a tmpfs.
|
||||
- **D190** (#543, beyond the concept): a trash review where nothing can move shows a primary
|
||||
that clearly cannot act: `aria-disabled`, no danger colors (also on hover), and a press does
|
||||
nothing. Tests: `tests/browser/workspace-images-delete.spec.mjs`.
|
||||
- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the
|
||||
freedesktop Trash specification (home trash on the same mount, else the mount's
|
||||
`.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with
|
||||
@@ -2641,8 +2677,8 @@ applied), D35 (singular captions), D36 (sorted categories), D40 (the continuatio
|
||||
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
|
||||
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
|
||||
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
|
||||
"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open
|
||||
existing prompt library") and the sidebar still link `/prompt_manager/admin`
|
||||
"Current prompt library" link is removed from the shelf; the legacy Library stays at
|
||||
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
|
||||
(`tests/browser/workspace.spec.mjs`). `library comparison detects
|
||||
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
|
||||
|
||||
@@ -2888,6 +2924,14 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library
|
||||
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
||||
change: ComfyUI needs a restart.
|
||||
|
||||
### Pair node gives a new prompt its category and tags (#546)
|
||||
|
||||
A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that
|
||||
save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as
|
||||
another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes
|
||||
no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550.
|
||||
Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart.
|
||||
|
||||
### Delete… on the archived shelf (#492)
|
||||
|
||||
Extends **D160** (coordinator ruling 2026-10-29, cutover item): the archived shelf's bar reads "N selected · Restore ·
|
||||
@@ -2928,6 +2972,29 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete
|
||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
|
||||
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
|
||||
|
||||
### Card tops, the Reusable tags window and System's width (#530)
|
||||
|
||||
Found in the maintainer's test of the cutover branch; each departs from the concept, whose files are not edited. The
|
||||
comparisons add `CONCEPT_LAYOUT_DEVIATIONS` (`concept-inventory.mjs`, four rules: card margins, System limit, tag header
|
||||
background and line) to the opened concept page, so the boxes that follow from them are still compared. The new spec pins the
|
||||
real values and that the unmapped concept keeps its 18px offset.
|
||||
|
||||
- **D182**: the second card of a `.two-col` starts level with the first. The concept's `.surface + .surface` 18px margin
|
||||
also applies inside its grid, which put Categories and Workspace preferences 18px lower. Stacked cards are now one 18px
|
||||
gap apart instead of 36px. A surface after a two-column row (Backup & export's Recovery policy) touched the row in the
|
||||
concept and on main; it now sits 18px below it.
|
||||
- **D183**: Reusable tags scrolls in its own window inside the card (`max(280px, 100dvh - 500px)`), so a long vocabulary no
|
||||
longer runs the page off screen. The column headers stick to the top of the window, with the surface background and a
|
||||
drawn line. Load more stays below the window; the end of the list inside the window (`.vocabulary-end`, observed with
|
||||
the window as root) appends the next page. A new search or sort returns the window to its top. Reaching Load more by
|
||||
keyboard no longer appends on its own; Enter does. A rename or merge reloads page one, so the window returns to its top.
|
||||
- **D184**: `.settings-layout` has no 1000px limit, so every System section uses the full content width, as the section
|
||||
nav and the other pages do.
|
||||
- **D185**: on touch and at 390px the same window stays shorter than the screen and overscroll chains to the page, so a
|
||||
swipe outside the window, or past its top, moves the page.
|
||||
|
||||
Measured in `workspace-layout-tags-system.spec.mjs`; the swipe case runs in the touch project.
|
||||
|
||||
### Prompt page heading on one row (#524)
|
||||
|
||||
**D164** (beyond the concept, which never has four heading actions beside a long title): from 951px, the concept's
|
||||
@@ -3117,8 +3184,8 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
|
||||
### Inspect a file page and recorded deviations (#249 local Inspect, PR L2)
|
||||
|
||||
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
|
||||
the legacy metadata page, which stays reachable from Overview's "Open existing metadata
|
||||
inspector" card (`/prompt_manager/gallery.html`). The page is the concept's `#metadata`
|
||||
the legacy metadata page, which stays at its URL
|
||||
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
|
||||
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
|
||||
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
|
||||
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
|
||||
@@ -3396,15 +3463,20 @@ document title "Review" (the concept's), heading "Metadata review" with its line
|
||||
concept's sentence, then `.gallery-layout`: the queue's concept media cards
|
||||
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
|
||||
the item, Tag opens the shared Tag image dialog with the review scope note) and
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Sampler context,
|
||||
the chosen path's prompt blocks, Leave for later and primary Apply association). The
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Link to a prompt…,
|
||||
Sampler context, the chosen path's prompt blocks, Leave for later, Leave unresolved and
|
||||
primary Apply association). The
|
||||
first item is selected on load, as the concept's first image is. Sampler context lists
|
||||
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
|
||||
unusable ones, preselects the first workable one and ends with "Leave unresolved"
|
||||
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
|
||||
unusable ones with the server's reason and preselects the first workable one
|
||||
(rulings R2/R3). Apply
|
||||
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
|
||||
under no current root posts without a capture. Both actions return to Images (R7); a
|
||||
recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
under no current root posts without a capture. Each decision and Leave for later opens
|
||||
the next item and the page stays (#536 overturns ruling R7); Leave for later writes
|
||||
nothing. Link to a prompt… (#536) records a Library prompt the reviewer chose,
|
||||
`POST /prompt_manager/review/items/{id}/link`: the capture guards of Apply, the same
|
||||
one-prompt-per-file rules, and link provenance `kind: "manual-link"` with no prompt
|
||||
sides, so it never reads as file evidence. A recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
refused decision keeps the selection and shows the server's sentence (the
|
||||
association conflict's explanation included) as `p.warning-note` in the panel; a
|
||||
restored storage or a changed queue reads the queue again; a request with no answer
|
||||
@@ -3419,6 +3491,14 @@ pending count (`GET /prompt_manager/review/items?limit=1`, its `total`).
|
||||
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
|
||||
item after the item's line, and in its job view's heading after Download report. The
|
||||
item's chip still names its state (D54), where the concept's says "Needs attention".
|
||||
- **D179** (#536, maintainer decision 2026-10-30): "Leave unresolved" is a button beside
|
||||
Leave for later, not the Sampler context select's last option; a disabled option names
|
||||
the server's reason.
|
||||
- **D180** (#536): with nothing to apply, the select, the prompt blocks and Apply are
|
||||
hidden; the panel gives the path count and each reason, and Link to a prompt… is primary.
|
||||
- **D181** (#536): the concept has no picker. Link to a prompt… opens the shared content
|
||||
dialog: Library search, the prompt's recipes as Negative prompt (one preselected,
|
||||
several must be chosen, always "No negative"), Cancel and Link image.
|
||||
- **D119**: the queue cards have no selection checkbox and
|
||||
no star (the page has no selection bar and the queue carries no favorite state); a
|
||||
file under no current root has an empty, `aria-disabled` image button and Tag; more
|
||||
@@ -3992,6 +4072,23 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||
- **D170** (#532, maintainer request 2026-10-30, beyond the concept): every viewer has
|
||||
`button#viewer-keys.icon-btn` right before the D156 view switch (accessible name and
|
||||
`title` "Keyboard shortcuts", a `command` outline icon, hidden at ≤700px with the
|
||||
switch). It opens `dialog#viewer-keys-dialog`, a modal built on demand inside the
|
||||
viewer, so a closed overlay adds nothing to the concept comparisons but the button:
|
||||
`.dialog-header` with × and the keys grouped as Move (← → Space), Image (+ - 0 R H),
|
||||
View (F I ? Esc) and File (D, Delete where the viewer has Delete…), one column at
|
||||
390px. Esc or × closes only the overlay and focus returns to the button; opening it
|
||||
stops the slideshow. ← and → navigate from anywhere in the viewer, on `document` as the
|
||||
legacy gallery does, unless a modifier is held, text is selected, another dialog is on
|
||||
top, or focus is in a video, form field, `pre`, `summary`, or anything in the details
|
||||
but a button or link. The other keys share that gate but allow Shift, and each clicks
|
||||
the toolbar's own control, so an aria-disabled, missing or hidden control does nothing
|
||||
(Rotate on a video, I when Centered, F at ≤700px, Delete without Delete…). `?` opens
|
||||
the overlay directly. Delete only opens the review. Space on a focused button or link
|
||||
activates it instead, and only + and - repeat. `workspace-viewer-keys.spec.mjs` pins
|
||||
this.
|
||||
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
|
||||
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
|
||||
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
|
||||
@@ -4235,6 +4332,24 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
||||
long file name keeps the concept's two equal columns at phone width.
|
||||
- **D173** (#535, amends D106; maintainer 2026-10-30): "always asks" now means asks when
|
||||
there is a choice. One eligible window is selected, and a selected window with exactly
|
||||
one eligible node selects that node; two or more nodes are never selected. A node whose
|
||||
requested field is linked is not offered (`linkedRoles` on each destination), and the
|
||||
status line says which case applies: linked, wrong roles, no node, or no window. It
|
||||
counts the other nodes that cannot receive, and the review names the node and window.
|
||||
- **D174** (#535): the window option shows the tab's current title, read at each discovery
|
||||
(a getter on the host options, which the Toolkit reads per discover), without the old
|
||||
"PromptManager —" prefix. Same-named windows get " (window 2)", numbered by host id so the
|
||||
number is stable while the tabs stay loaded. Every node option reads "#<id> <title>", so
|
||||
identical nodes can be told apart. Discovery waits 300 ms, as System does.
|
||||
- **D175** (#535, maintainer 2026-10-30): the concept README previews before applying;
|
||||
production makes Preview optional. Apply is enabled once a window and node are selected
|
||||
and requests the ticket itself, in one click. It stays closed after "Sent to ComfyUI.",
|
||||
so a double click sends once, and after an unknown outcome until a new preview or
|
||||
choice. The host's checks still refuse a linked field or a changed workflow, and the
|
||||
sender also refuses if the destination's `revision` (node object plus graph version)
|
||||
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
|
||||
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
||||
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
|
||||
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
|
||||
@@ -4340,6 +4455,15 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
`system shell and overview match the concept` (the mapping inserts the row) and
|
||||
`library notice matches the concept notice` with its mutation check
|
||||
(system-concept.spec), and the D129 tests of workspace-system.spec.
|
||||
- **D166** (one-time upgrade notice, #527): the concept's README puts the notice in the
|
||||
new shell, while its `app.js` draws it in System › Sources. Production does both: the
|
||||
Sources `.notice` is kept and compared as drawn, and `#older-images-notice`, styled as
|
||||
`#library-banner`, shows the same notice above every other page until dismissed. The
|
||||
shell hides on Sources, so they never show together. Dismissal is the legacy key
|
||||
`pm_update_notice_dismissed`, shared with the admin modal and across tabs. Browser
|
||||
contexts start dismissed (`tests/browser/fixtures.mjs`, `storageState`); a spec about
|
||||
the notice sets `test.use({ olderImagesNotice: true })`. Pinned by
|
||||
workspace-older-images-notice.spec.
|
||||
- **D130** (Appearance, #317): the concept keeps "Thumbnail quality, size limit" in
|
||||
Appearance (README) but draws no rows for them. Appearance gains a second
|
||||
`section.surface`, "Thumbnails", after "Browsing & display", built from the concept's
|
||||
@@ -5617,6 +5741,23 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-backups.spec.mj
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
## Sticky chrome (#531)
|
||||
|
||||
Beyond the concept, from the maintainer's test of #528; the concept is unchanged. `workspace-sticky-chrome.spec.mjs` holds these.
|
||||
|
||||
- **D186:** the selection bars (Images `#bulkbar`, Library `#library-bulkbar`; there are no others) are sticky: under the
|
||||
top bar at the top of the page, and at the top of the window once that non-sticky top bar has scrolled away (one rule
|
||||
in workspace.css). While the bar's flow place is above the window, its appearing, resizing or leaving does not move the cards
|
||||
(`keepingPlace`). Focused card controls and the Images panel keep clear of it (`--selection-bar-block`). At 700px and below
|
||||
the bar is one row that scrolls sideways. After a bulk action finishes the page scrolls to the top and refreshes the
|
||||
list; Set category, Delete… and Return to library clear the selection as before, while tagging and collections keep
|
||||
it (D85, D138). Export metadata changes nothing, so it stays put. Single-image actions from the inspector and viewer stay put.
|
||||
- **D187:** Back to top: one shell chip past 200px of scroll, above the toast's spot; it rises only for a toast taller than two lines. It never animates, because the
|
||||
pages' height corrections would cancel a smooth scroll, and focus moves to `#workspace-main`. D42's band grows to 140px.
|
||||
- **D188:** the Needs review panel is sticky like the Images panel, with its own scroll on a window under 720px tall, and static at 700px and below.
|
||||
- **D189:** where a panel is one column (static): Info brings the panel into view. On
|
||||
Images the panel follows the whole list, past infinite scroll, so loading waits for the reader's next move (wheel, touch or key) rather than pushing the panel away.
|
||||
|
||||
## Workspace data fields for the concept pages (#249)
|
||||
|
||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||
@@ -6656,7 +6797,7 @@ checkout or point this harness at a live ComfyUI checkout.
|
||||
|
||||
The package-qualified config import also corrects #211: the configured database
|
||||
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
|
||||
[database path compatibility and safe backup sequence](README.md#database-path-compatibility-211)
|
||||
[database path compatibility and safe backup sequence](README-legacy.md#database-path-compatibility-211)
|
||||
before restarting an existing deployment.
|
||||
|
||||
The ledger records only new dequeued jobs and observed outcomes. Existing
|
||||
@@ -7029,8 +7170,8 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
## Native vocabulary UI (#236)
|
||||
|
||||
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
|
||||
longer hands off to the legacy tag manager. That page stays reachable from Overview's
|
||||
"Open existing tag manager" card for its own page; tag search and sort, the untagged
|
||||
longer hands off to the legacy tag manager. That page stays at
|
||||
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
|
||||
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
|
||||
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
|
||||
library's selection bar, D151). A prompt's category is changed in the Prompt library
|
||||
@@ -7152,3 +7293,23 @@ until acceptance. Not compared: hover states and the dialog focus ring (the page
|
||||
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
|
||||
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
|
||||
floors in `frontend-coverage-baseline.json`.
|
||||
|
||||
## Cutover entry points (#525)
|
||||
|
||||
The top-bar PM button, both Web UI buttons and `/prompt_manager/web` open
|
||||
`/prompt_manager/workspace#/library` (`web/js/workspace-entry.js`; `web/index.html` keeps its
|
||||
own copy, pinned by `tests/js/workspace-entry.test.js`). Overview's cards open native hashes.
|
||||
The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.html`) stay at
|
||||
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
|
||||
ruling; pinned by `legacy-system-links.spec.mjs`).
|
||||
|
||||
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
|
||||
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
|
||||
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
|
||||
`mapOverviewConcept` adds it to the page tree.
|
||||
- **Load failure** (maintainer ruling 2026-10-30): `web/workspace.html` holds static
|
||||
`#workspace-boot-failure` with the same four links, styled inline so it needs no stylesheet.
|
||||
An inline guard shows it at `load` unless `index.mjs` set `data-workspace-booted`, so a failed
|
||||
fetch of any module and a throw while starting are both covered; `workspace.spec.mjs` tests
|
||||
both. Editing `workspace.html` needs a ComfyUI restart, because the server caches it. The PM button has no
|
||||
page-fixture test; it is proven on the managed host (`tests/comfyui/prompt-manager.spec.mjs`).
|
||||
|
||||
@@ -120,6 +120,7 @@ except Exception as e:
|
||||
logger = get_logger("prompt_manager.init")
|
||||
logger.error(f"Failed to register API routes: {e}")
|
||||
except Exception:
|
||||
# logging failed too; a report must never stop the node loading
|
||||
pass
|
||||
|
||||
# Shared database, set up with the monitoring system below. Stays None when that
|
||||
@@ -411,6 +412,7 @@ except Exception as e:
|
||||
f"Failed to register the exit signal: {e}"
|
||||
)
|
||||
except Exception:
|
||||
# logging failed too; a report must never stop the node loading
|
||||
pass
|
||||
|
||||
try:
|
||||
@@ -428,6 +430,7 @@ except Exception as e:
|
||||
f"Failed to register the background work teardown: {e}"
|
||||
)
|
||||
except Exception:
|
||||
# logging failed too; a report must never stop the node loading
|
||||
pass
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -590,6 +590,7 @@ class _Move:
|
||||
try:
|
||||
os.rmdir(self.plan.target_dir)
|
||||
except OSError:
|
||||
# not empty or already gone: the undo leaves it alone
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -35,38 +35,51 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import operator
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from typing import Iterable, Iterator, List, Optional
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Iterable, Iterator, List, Optional
|
||||
|
||||
import sqlite3
|
||||
|
||||
CLOSED_MESSAGE = "Cannot operate on a closed database."
|
||||
# Rows fetched per step when a cursor is iterated: the connection is marked
|
||||
# in use once per chunk instead of once per row (iteration costs about what
|
||||
# a plain cursor costs). A loop that stops early leaves up to this many rows
|
||||
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
|
||||
# a plain cursor costs). Prefetched rows belong to the cursor so iteration
|
||||
# and explicit fetches always advance the same position.
|
||||
ITER_CHUNK = 128
|
||||
|
||||
|
||||
class ManagedCursor(sqlite3.Cursor):
|
||||
"""A cursor whose calls into SQLite count as its connection being in use."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._pending = deque()
|
||||
self._closed = False
|
||||
|
||||
def execute(self, sql, parameters=(), /):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
return super().execute(sql, parameters)
|
||||
finally:
|
||||
conn._leave()
|
||||
return self._run_statement(super().execute, sql, parameters)
|
||||
|
||||
def executemany(self, sql, seq_of_parameters, /):
|
||||
return self._run_statement(super().executemany, sql, seq_of_parameters)
|
||||
|
||||
def _run_statement(self, operation: Callable, *args: Any) -> ManagedCursor:
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
description = self.description
|
||||
completed = False
|
||||
try:
|
||||
return super().executemany(sql, seq_of_parameters)
|
||||
result = operation(*args)
|
||||
completed = True
|
||||
return result
|
||||
finally:
|
||||
# Argument parsing can fail before SQLite replaces the old statement.
|
||||
if completed or self.description is not description:
|
||||
self._pending.clear()
|
||||
conn._leave()
|
||||
|
||||
def executescript(self, sql_script, /):
|
||||
@@ -81,6 +94,8 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
if self._pending and not self._closed:
|
||||
return self._pending.popleft()
|
||||
return super().fetchone()
|
||||
finally:
|
||||
conn._leave()
|
||||
@@ -89,7 +104,20 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
return super().fetchmany(*args, **kwargs)
|
||||
# Let SQLite validate the original arguments, including version-specific
|
||||
# handling of zero/negative sizes, before touching prefetched rows.
|
||||
rows = super().fetchmany(*args, **kwargs)
|
||||
if not self._pending:
|
||||
return rows
|
||||
size = operator.index(
|
||||
kwargs.get("size", args[0] if args else self.arraysize)
|
||||
)
|
||||
# Python 3.12 reads all rows for size=0; 3.13+ reads none.
|
||||
if size == 0 and sys.version_info >= (3, 13):
|
||||
return rows
|
||||
self._pending.extend(rows)
|
||||
count = len(self._pending) if size <= 0 else min(size, len(self._pending))
|
||||
return [self._pending.popleft() for _ in range(count)]
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
@@ -97,25 +125,31 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
return super().fetchall()
|
||||
rows = super().fetchall()
|
||||
pending = list(self._pending)
|
||||
self._pending.clear()
|
||||
return pending + rows
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
return self._chunks()
|
||||
|
||||
def _chunks(self) -> Iterator:
|
||||
while True:
|
||||
rows = self.fetchmany(ITER_CHUNK)
|
||||
if not rows:
|
||||
return
|
||||
yield from rows
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
conn = self.connection
|
||||
pending = self._pending
|
||||
# Buffered Python rows need no SQLite lock, but retirement and cursor
|
||||
# close must still stop an iterator obtained before either operation.
|
||||
if conn._retired or self._closed:
|
||||
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
|
||||
if pending:
|
||||
return pending.popleft()
|
||||
conn._enter()
|
||||
try:
|
||||
return super().__next__()
|
||||
pending.extend(super().fetchmany(ITER_CHUNK))
|
||||
if not pending:
|
||||
raise StopIteration
|
||||
return pending.popleft()
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
@@ -126,7 +160,10 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
except sqlite3.ProgrammingError:
|
||||
return super().close() # the connection is closed or closing
|
||||
try:
|
||||
return super().close()
|
||||
result = super().close()
|
||||
self._pending = deque()
|
||||
self._closed = True
|
||||
return result
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
@@ -293,6 +330,9 @@ class ManagedConnection(sqlite3.Connection):
|
||||
cursors = list(self._cursors)
|
||||
try:
|
||||
for cursor in cursors:
|
||||
if isinstance(cursor, ManagedCursor):
|
||||
cursor._pending = deque()
|
||||
cursor._closed = True
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
sqlite3.Cursor.close(cursor)
|
||||
super().close()
|
||||
|
||||
@@ -34,7 +34,7 @@ from .history_reads import HistoryReadOperations
|
||||
from .health import HealthOperations, iter_consistency_issues
|
||||
from .duplicate_copies import DuplicateCopyOperations
|
||||
from .workflow_storage import WorkflowStorageOperations
|
||||
from .category_import import CategoryImportOperations, EmptyReplacement # noqa: F401
|
||||
from .category_import import CategoryImportOperations
|
||||
from .image_categories import ImageCategoryOperations
|
||||
from .workflow_documents import (
|
||||
DocumentReader,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,6 +46,7 @@ def remove_quietly(*paths):
|
||||
if path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
# cleanup after a failure must not replace that failure
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
|
||||
)
|
||||
|
||||
|
||||
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
|
||||
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
|
||||
# "created" follow the id, so every order is total and keyset pages are exact.
|
||||
TAG_SORTS = {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -36,10 +36,18 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from ...utils import exit_signal
|
||||
from ...utils.media_folders import is_skipped_folder
|
||||
from ...utils.media_folders import (
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
except ImportError:
|
||||
from utils import exit_signal
|
||||
from utils.media_folders import is_skipped_folder
|
||||
from utils.media_folders import (
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
|
||||
try:
|
||||
from ...database.image_removal import mark_hides
|
||||
@@ -168,7 +176,7 @@ def _physical_file_key(path, stat):
|
||||
volumes on Windows) falls back to the resolved, case-folded path.
|
||||
"""
|
||||
if stat.st_ino:
|
||||
return ("inode", stat.st_dev, stat.st_ino)
|
||||
return ("inode", (stat.st_dev, stat.st_ino))
|
||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||
|
||||
|
||||
@@ -531,6 +539,7 @@ def _diagnose_output_dirs(configured_dirs, public_path=None):
|
||||
|
||||
candidates.append(folder_paths.get_output_directory())
|
||||
except ImportError:
|
||||
# outside ComfyUI there is no output folder to add
|
||||
pass
|
||||
|
||||
output_dirs = []
|
||||
@@ -1089,13 +1098,21 @@ class AdminRoutesMixin:
|
||||
return result
|
||||
|
||||
def _delete_one_duplicate(self, image_path, output_path):
|
||||
"""Delete one file and its thumbnail; return a failure reason or None."""
|
||||
"""Delete one file and its thumbnail; return a failure reason or None.
|
||||
|
||||
What is removed is exactly the folder's real path plus the name: a
|
||||
link is removed itself, never its target, and the preview removed is
|
||||
that name's. Both the link's place and what it leads to are checked.
|
||||
"""
|
||||
file_path = Path(image_path)
|
||||
if not file_path.is_absolute():
|
||||
file_path = output_path / file_path
|
||||
|
||||
try:
|
||||
rel_path = file_path.resolve().relative_to(output_path.resolve())
|
||||
real_root = output_path.resolve()
|
||||
rel_path = file_path.resolve().relative_to(real_root)
|
||||
exact = Path(os.path.realpath(file_path.parent)) / file_path.name
|
||||
exact_rel = exact.relative_to(real_root)
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
f"Attempted to delete file outside output directory: {image_path}"
|
||||
@@ -1104,15 +1121,19 @@ class AdminRoutesMixin:
|
||||
|
||||
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||
return "not a media file"
|
||||
if "thumbnails" in rel_path.parts:
|
||||
if "thumbnails" in rel_path.parts or "thumbnails" in exact_rel.parts:
|
||||
return "thumbnails are managed by the gallery"
|
||||
if in_trash_below_roots(file_path, [output_path]) or in_trash_folder(
|
||||
str(exact_rel)
|
||||
):
|
||||
return "it is in a trash folder" # never unlinked from a trash (#543)
|
||||
|
||||
if not file_path.is_file():
|
||||
return "file not found"
|
||||
|
||||
os.remove(file_path)
|
||||
os.remove(exact)
|
||||
self.logger.info(f"Deleted duplicate image: {image_path}")
|
||||
_remove_thumbnail(output_path.resolve(), rel_path, self.logger)
|
||||
_remove_thumbnail(real_root, exact_rel, self.logger)
|
||||
return None
|
||||
|
||||
async def get_statistics(self, request):
|
||||
|
||||
@@ -13,7 +13,6 @@ import math
|
||||
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
|
||||
LLM_MODEL_TYPES as LLM_MODELS,
|
||||
MAX_INSTRUCTION_BYTES,
|
||||
MAX_INSTRUCTION_CHARS,
|
||||
MODEL_TYPES,
|
||||
WD14_MODEL_TYPES as WD14_MODELS,
|
||||
validate_template,
|
||||
|
||||
@@ -24,9 +24,9 @@ from . import thumbnail_settings
|
||||
from .. import model_gate
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import is_skipped_folder
|
||||
from ...utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||
except ImportError:
|
||||
from utils.media_folders import is_skipped_folder
|
||||
from utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -424,6 +424,7 @@ class AutotagRoutesMixin:
|
||||
title = f"Tagging model download · {label}"
|
||||
_download_titles.clear()
|
||||
_download_titles[owner] = title
|
||||
admitted = False
|
||||
try:
|
||||
job = self._jobs.start(
|
||||
MODEL_DOWNLOAD_JOB_KIND,
|
||||
@@ -439,13 +440,13 @@ class AutotagRoutesMixin:
|
||||
self._run_model_download, service, model_type, owner
|
||||
),
|
||||
)
|
||||
except BaseException as refused:
|
||||
# JobBusy, or anything else the registry raised: give the gate back.
|
||||
model_gate.release(owner)
|
||||
_download_titles.pop(owner, None)
|
||||
if isinstance(refused, JobBusy):
|
||||
return None, busy_response(refused)
|
||||
raise
|
||||
admitted = True
|
||||
except JobBusy as refused:
|
||||
return None, busy_response(refused)
|
||||
finally:
|
||||
if not admitted: # JobBusy, or anything else: give the gate back.
|
||||
model_gate.release(owner)
|
||||
_download_titles.pop(owner, None)
|
||||
|
||||
def ended(_task):
|
||||
model_gate.release(owner)
|
||||
@@ -849,9 +850,12 @@ class AutotagRoutesMixin:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
return None
|
||||
for output_dir in self._get_all_output_dirs():
|
||||
roots = list(self._get_all_output_dirs())
|
||||
for output_dir in roots:
|
||||
if path_is_within(raw_path, output_dir):
|
||||
real = os.path.realpath(raw_path)
|
||||
if in_trash_below_roots(real, roots):
|
||||
return None # thrown away, not an output (#543)
|
||||
return real if os.path.isfile(real) else None
|
||||
return None
|
||||
|
||||
@@ -1038,12 +1042,12 @@ class AutotagRoutesMixin:
|
||||
return self._bad_request('existing must be "skip" or "merge"')
|
||||
try:
|
||||
options = self._single_options(data, model_type)
|
||||
except OptionError as error:
|
||||
return self._bad_request(str(error))
|
||||
except (ValueError, TypeError):
|
||||
return self._bad_request(
|
||||
"general_threshold and character_threshold must be numeric"
|
||||
)
|
||||
except OptionError as error:
|
||||
return self._bad_request(str(error))
|
||||
target, error = await self._select_autotag_target(data)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
@@ -971,6 +971,7 @@ class BackupRestoreRoutesMixin:
|
||||
line = restored_line(staged.summary)
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
|
||||
async def _outlive_cancel(future):
|
||||
|
||||
@@ -574,12 +574,13 @@ class DuplicateFilesMixin:
|
||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
return None
|
||||
try:
|
||||
await self._remove_duplicates(job, db, plan, backup)
|
||||
finally:
|
||||
if backup:
|
||||
await self._prune_backup_pool(job, db)
|
||||
return None
|
||||
|
||||
async def _remove_duplicates(self, job, db, plan, backup):
|
||||
targets = plan.payload["targets"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -27,7 +27,7 @@ def _identity(path):
|
||||
except OSError:
|
||||
return None
|
||||
if info.st_ino:
|
||||
return ("inode", info.st_dev, info.st_ino)
|
||||
return ("inode", (info.st_dev, info.st_ino))
|
||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -330,12 +330,13 @@ class ImageDeleteMixin:
|
||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
return None
|
||||
try:
|
||||
await self._delete_images(job, db, targets, mode, backup)
|
||||
finally:
|
||||
if backup:
|
||||
await self._prune_backup_pool(job, db)
|
||||
return None
|
||||
|
||||
async def _delete_images(self, job, db, targets, mode, backup):
|
||||
job.update(total=len(targets), status_line=TITLES[mode])
|
||||
|
||||
@@ -531,6 +531,7 @@ class ImageTagJobsMixin:
|
||||
run = AutotagRun(
|
||||
model_type, existing, options, keep_in_memory, backup_on, owner
|
||||
)
|
||||
admitted = False
|
||||
try:
|
||||
job = self._jobs.start(
|
||||
AUTOTAG_BATCH_JOB_KIND,
|
||||
@@ -541,11 +542,12 @@ class ImageTagJobsMixin:
|
||||
groups=AUTOTAG_GROUP_NAMES,
|
||||
runner=functools.partial(self._run_autotag_batch, run),
|
||||
)
|
||||
except BaseException as refused:
|
||||
model_gate.release(owner)
|
||||
if isinstance(refused, JobBusy):
|
||||
return None, _busy_refusal(refused)
|
||||
raise
|
||||
admitted = True
|
||||
except JobBusy as refused:
|
||||
return None, _busy_refusal(refused)
|
||||
finally:
|
||||
if not admitted:
|
||||
model_gate.release(owner)
|
||||
job.task.add_done_callback(lambda _task: model_gate.release(owner))
|
||||
return job, None
|
||||
|
||||
|
||||
@@ -39,10 +39,8 @@ from .thumbnail_encode import (
|
||||
PREVIEW_HELD,
|
||||
EncoderClosed,
|
||||
PreviewWriteHeld,
|
||||
replace_unless_held,
|
||||
run_encoder,
|
||||
sweep_stray_temps,
|
||||
temp_sibling,
|
||||
thumbnail_frame,
|
||||
writer_settings,
|
||||
write_thumbnail,
|
||||
@@ -66,7 +64,12 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...database.run_attribution import recorded_output_lookup
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from ...utils.media_folders import (
|
||||
in_skipped_folder,
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.json_strict import dumps_strict
|
||||
from ...utils.inspection_response import (
|
||||
@@ -80,7 +83,7 @@ try:
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
is_same_origin_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
@@ -92,7 +95,12 @@ except ImportError:
|
||||
from database.models import normalize_image_path
|
||||
from database.run_attribution import recorded_output_lookup
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from utils.media_folders import (
|
||||
in_skipped_folder,
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
from utils.parallel import map_parallel
|
||||
from utils.json_strict import dumps_strict
|
||||
from utils.inspection_response import (
|
||||
@@ -106,7 +114,7 @@ except ImportError:
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
is_same_origin_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
@@ -277,10 +285,20 @@ def _forbidden(message="Access denied"):
|
||||
return web.json_response({"success": False, "error": message}, status=403)
|
||||
|
||||
|
||||
def _media_access_error(path, allowed_dirs):
|
||||
"""Return a 403 response when *path* must not be served, else None.
|
||||
def _file_not_found():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"},
|
||||
status=404,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
Fails closed: an empty *allowed_dirs* denies everything.
|
||||
|
||||
def _media_access_error(path, allowed_dirs):
|
||||
"""The response refusing *path*, or None when it may be read.
|
||||
|
||||
Fails closed: an empty *allowed_dirs* denies everything (403). A file in a
|
||||
trash folder below its root is thrown away, not an output: a 404, as if it
|
||||
were not there (#543), also through a link or another spelling.
|
||||
"""
|
||||
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _forbidden("Only media files can be served")
|
||||
@@ -288,6 +306,8 @@ def _media_access_error(path, allowed_dirs):
|
||||
return _forbidden("No allowed image directories configured")
|
||||
if not any(_is_within(path, root) for root in allowed_dirs):
|
||||
return _forbidden()
|
||||
if in_trash_below_roots(path, allowed_dirs):
|
||||
return _file_not_found()
|
||||
return None
|
||||
|
||||
|
||||
@@ -317,11 +337,6 @@ def _thumbnail_batch_size(workers):
|
||||
return max(4, workers * 2)
|
||||
|
||||
|
||||
# Preview files are written by thumbnail_encode in the configured format
|
||||
# (thumbnail_settings, #317); these names stay importable from here.
|
||||
_temp_sibling = temp_sibling
|
||||
_replace_unless_held = replace_unless_held
|
||||
|
||||
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
|
||||
# over a file another process has open (WinError 5 or 32), such as a preview
|
||||
# the gallery is serving; the old preview stays and the file counts as an error.
|
||||
@@ -374,6 +389,7 @@ def _video_placeholder_image(thumbnail_size):
|
||||
font=font,
|
||||
)
|
||||
except (OSError, AttributeError):
|
||||
# a placeholder without its label still works
|
||||
pass
|
||||
return img
|
||||
|
||||
@@ -477,6 +493,7 @@ def _preview_names(folders, folder):
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
# an unreadable folder lists as empty; nothing there to cache
|
||||
pass
|
||||
folders[folder] = names
|
||||
return names
|
||||
@@ -795,12 +812,12 @@ def _reveal_not_found():
|
||||
)
|
||||
|
||||
|
||||
def _not_local():
|
||||
def _not_same_origin():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "not-local",
|
||||
"error": "Show in folder works only on the computer running ComfyUI.",
|
||||
"code": "not-same-origin",
|
||||
"error": "This page's address does not match the server's.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
@@ -979,6 +996,8 @@ def _settled_entry(root, relative):
|
||||
return None
|
||||
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
|
||||
return None
|
||||
if in_trash_below_roots(path, [root]):
|
||||
return None # a linked folder on the way leads into a trash folder
|
||||
folder = os.fspath(root)
|
||||
for part in ("", *folders):
|
||||
folder = os.path.join(folder, part) if part else folder
|
||||
@@ -1514,7 +1533,7 @@ class ImageRoutesMixin:
|
||||
async def clear_image_favorite_route(request):
|
||||
return await self.update_image_favorite(request, favorite=False)
|
||||
|
||||
# POST, local-only: opens this computer's file manager (D38).
|
||||
# POST, same-origin: opens the server computer's file manager (D38).
|
||||
@routes.post("/prompt_manager/images/reveal")
|
||||
async def reveal_image_route(request):
|
||||
return await self.reveal_image(request)
|
||||
@@ -2761,6 +2780,9 @@ class ImageRoutesMixin:
|
||||
if _has_traversal(filepath):
|
||||
return _forbidden()
|
||||
suffix = Path(filepath).suffix.lower()
|
||||
if in_trash_folder(filepath):
|
||||
# Before any file access, so no preview is made for it (#543).
|
||||
return _file_not_found()
|
||||
if suffix not in SERVABLE_EXTENSIONS and not (
|
||||
# A JPEG XL preview is served from thumbnails/ only (#317).
|
||||
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
@@ -2784,6 +2806,8 @@ class ImageRoutesMixin:
|
||||
# a fall-through to the next root.
|
||||
if not _is_within(image_path, output_path):
|
||||
return _forbidden()
|
||||
if in_trash_below_roots(image_path, output_dirs):
|
||||
return _file_not_found()
|
||||
if image_path.is_file():
|
||||
return self._file_response(image_path)
|
||||
|
||||
@@ -2835,8 +2859,9 @@ class ImageRoutesMixin:
|
||||
if _is_thumbnail_cache_path(filepath):
|
||||
return _forbidden()
|
||||
image_path = root / filepath
|
||||
if not _is_within(image_path, root):
|
||||
return _forbidden()
|
||||
denied = _media_access_error(image_path, [root])
|
||||
if denied is not None:
|
||||
return denied
|
||||
if not image_path.is_file():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
@@ -3682,12 +3707,12 @@ class ImageRoutesMixin:
|
||||
async def reveal_image(self, request):
|
||||
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
|
||||
|
||||
Local-only (a same-origin browser request from a loopback client, no
|
||||
forwarding header) and JSON-only: the browser must run on the
|
||||
computer whose file manager opens. One reveal at a time (429 busy).
|
||||
Same-origin and JSON-only, directly or through a proxy: the folder
|
||||
opens on the computer running ComfyUI, wherever the browser is
|
||||
(maintainer decision, #533). One reveal at a time (429 busy).
|
||||
"""
|
||||
if not is_local_request(request.remote, request.headers, request.scheme):
|
||||
return _not_local()
|
||||
if not is_same_origin_request(request.headers, request.scheme):
|
||||
return _not_same_origin()
|
||||
if request.content_type != "application/json":
|
||||
# A non-JSON POST is a CORS "simple" request with no preflight.
|
||||
return web.json_response(
|
||||
@@ -3765,7 +3790,9 @@ class ImageRoutesMixin:
|
||||
{
|
||||
"success": False,
|
||||
"code": "unavailable",
|
||||
"error": "No file manager is available on this computer.",
|
||||
"error": (
|
||||
"No file manager is available on the computer running ComfyUI."
|
||||
),
|
||||
},
|
||||
status=501,
|
||||
)
|
||||
|
||||
@@ -250,7 +250,7 @@ class JobRoutesMixin:
|
||||
try:
|
||||
await response.write(_summary_event(job))
|
||||
async with contextlib.aclosing(job.events()) as events:
|
||||
async for _payload in events:
|
||||
async for _ in events:
|
||||
await response.write(_summary_event(job))
|
||||
# The last event is published before the job takes its final state.
|
||||
await response.write(_summary_event(job))
|
||||
|
||||
@@ -207,6 +207,7 @@ class LibraryLocationRoutesMixin:
|
||||
try:
|
||||
size = os.path.getsize(location.db_path)
|
||||
except OSError:
|
||||
# no library file yet: report size 0
|
||||
pass
|
||||
return {
|
||||
"path": self._library_public_path(location.db_path),
|
||||
@@ -373,6 +374,7 @@ class LibraryLocationRoutesMixin:
|
||||
line += ". Logs move at the next restart."
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _fail_library_move(job, code, message):
|
||||
|
||||
@@ -205,3 +205,4 @@ class LogCleanupMixin:
|
||||
line += f", {failed} could not be deleted"
|
||||
job.log_line(line)
|
||||
job.update(processed=len(files), progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
@@ -436,7 +436,7 @@ class LoraImportRoutesMixin:
|
||||
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
||||
job.update(status_line=CANCELLED)
|
||||
job.finish("cancelled")
|
||||
return
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception("LoRA import staging failed")
|
||||
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
|
||||
@@ -455,10 +455,10 @@ class LoraImportRoutesMixin:
|
||||
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
||||
job.update(status_line=CANCELLED)
|
||||
job.finish("cancelled")
|
||||
return
|
||||
return None
|
||||
ok, _backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
return None
|
||||
# I1: the files are listed and fingerprinted again right before the
|
||||
# replace, so a file changed, removed or added while the downloads ran
|
||||
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
|
||||
@@ -466,6 +466,7 @@ class LoraImportRoutesMixin:
|
||||
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
|
||||
job.update(status_line="Replacing the LoRA collection", progress=95)
|
||||
await self._replace_in_job(job, db, plan, staged)
|
||||
return None
|
||||
|
||||
def _count_downloads(self, job, staged):
|
||||
downloaded = staged.stats.get("downloaded", 0)
|
||||
@@ -518,3 +519,4 @@ class LoraImportRoutesMixin:
|
||||
staged.stats.get("download_errors", 0),
|
||||
)
|
||||
job.update(progress=100, processed=job.total, status_line=line)
|
||||
return None
|
||||
|
||||
@@ -44,6 +44,7 @@ _FILES_CHANGED = (
|
||||
"The LoRA metadata files changed during the scan; the previous imports "
|
||||
"were kept. Run the scan again."
|
||||
)
|
||||
_LORA_SCAN_FAILED = "The LoRA import failed. Check the server log for details."
|
||||
_IMPORTS_CHANGED = (
|
||||
"The imported LoRA prompts changed during the scan; the previous imports "
|
||||
"were kept. Run the scan again."
|
||||
@@ -407,10 +408,21 @@ class LoraIntegrationMixin:
|
||||
return
|
||||
if isinstance(error, PlanChanged):
|
||||
message = _IMPORTS_CHANGED
|
||||
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
|
||||
elif isinstance(
|
||||
error,
|
||||
(
|
||||
LoraFilesChanged,
|
||||
LoraSettingsUnusable,
|
||||
EmptyReplacement,
|
||||
PairDependencyError,
|
||||
),
|
||||
):
|
||||
message = str(error)
|
||||
else:
|
||||
elif isinstance(error, OSError):
|
||||
# Reduced to its reason and the file's basename.
|
||||
message = self._public_error(error)
|
||||
else:
|
||||
message = _LORA_SCAN_FAILED
|
||||
if isinstance(
|
||||
error,
|
||||
(
|
||||
@@ -536,6 +548,7 @@ class LoraIntegrationMixin:
|
||||
except asyncio.CancelledError:
|
||||
interrupted = True
|
||||
except Exception:
|
||||
# the await after this loop raises it for the caller
|
||||
pass
|
||||
if interrupted:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
@@ -673,6 +673,7 @@ class MaintenanceRoutesMixin:
|
||||
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
return None
|
||||
|
||||
def _maintenance_rows(self, operation, described):
|
||||
"""Outcome rows for the changed records (root-relative paths only)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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):
|
||||
@@ -528,6 +533,7 @@ class AutoThumbnailer:
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
# the process has already exited
|
||||
pass
|
||||
if worker is None:
|
||||
return True
|
||||
@@ -835,7 +841,7 @@ class AutoThumbnailMixin:
|
||||
if not folded.startswith(base) or not _is_within(path, root):
|
||||
continue
|
||||
rel = Path(os.path.relpath(path, root))
|
||||
if "thumbnails" in rel.parts[:-1]:
|
||||
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
|
||||
return None
|
||||
dst = root / media_thumbnail_rel(rel)
|
||||
if not _is_within(root / "thumbnails", root) or not _is_within(
|
||||
|
||||
@@ -158,6 +158,7 @@ def _unlink_quietly(path):
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except FileNotFoundError:
|
||||
# already gone, which is what was wanted
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
|
||||
@@ -221,6 +222,7 @@ def shutdown_encoders():
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
# the process has already exited
|
||||
pass
|
||||
|
||||
|
||||
@@ -359,10 +361,12 @@ def thumbnail_frame(img, edge):
|
||||
try:
|
||||
img.seek(0)
|
||||
except (EOFError, AttributeError, ValueError):
|
||||
# not seekable or a single frame: use the current one
|
||||
pass
|
||||
try:
|
||||
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
|
||||
except (AttributeError, ValueError, OSError):
|
||||
# draft is only a speedup; the full decode below still works
|
||||
pass
|
||||
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
|
||||
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
|
||||
|
||||
@@ -275,3 +275,4 @@ class WorkflowStorageRoutesMixin:
|
||||
job.record_counted("changed", 1)
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
@@ -109,6 +109,7 @@ def _write_atomically(path: str, payload: bytes) -> None:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
# nothing to clean up; the original error is raised below
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@@ -642,6 +642,7 @@ def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
|
||||
relative = Path(settings_file).relative_to(home)
|
||||
settings_file = os.path.join("~", str(relative))
|
||||
except ValueError:
|
||||
# outside the home folder: show the full path
|
||||
pass
|
||||
return {
|
||||
"source": layout.source,
|
||||
|
||||
@@ -585,6 +585,7 @@ def _read_capped(resp, cap: int) -> Optional[bytes]:
|
||||
if int(declared) > cap:
|
||||
return None
|
||||
except ValueError:
|
||||
# a bad header says nothing; the read below enforces the cap
|
||||
pass
|
||||
|
||||
chunks = []
|
||||
@@ -707,6 +708,7 @@ def _save_atomically(image, local_path: Path) -> None:
|
||||
try:
|
||||
os.remove(temp)
|
||||
except OSError:
|
||||
# nothing to clean up; the original error is raised below
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[project]
|
||||
name = "promptmanager"
|
||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||
version = "3.3.1"
|
||||
version = "3.4.1"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||
dependencies = ["watchdog>=2.1.0,<7", "Pillow>=8.0.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=84.2.1"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Runtime dependencies
|
||||
watchdog>=6.0.0
|
||||
watchdog>=6.0.0,<7
|
||||
Pillow>=12.3.0
|
||||
aiohttp>=3.14.5
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Runtime dependencies for PromptManager.
|
||||
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
|
||||
watchdog>=2.1.0 # Output directory monitoring
|
||||
watchdog>=2.1.0,<7 # Output directory monitoring
|
||||
Pillow>=8.0.0 # Image metadata extraction and thumbnails
|
||||
|
||||
@@ -6,7 +6,6 @@ import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate
|
||||
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
|
||||
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
|
||||
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
|
||||
@@ -7,7 +7,7 @@ test.beforeEach(async ({page, browserName}) => {
|
||||
page.classicErrors = [];
|
||||
page.on('pageerror', error => page.classicErrors.push(error.message));
|
||||
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
|
||||
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
|
||||
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => (window.fixtureApiBase ?? "") + path, queuePrompt: async (...args) => args};'}));
|
||||
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
|
||||
await page.goto('/prompt_manager/test');
|
||||
await page.evaluate(() => {
|
||||
@@ -64,6 +64,28 @@ test('Classic keeps immediate selection, search query and metadata serialization
|
||||
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('Classic Web UI opens the workspace Library in the saved display mode, through the mount prefix (#525)',async({page})=>{
|
||||
await page.evaluate(()=>{window.fixtureApiBase='/mounted';window.open=(...args)=>{window.openArgs=args;return {};};});
|
||||
const webUi=page.getByRole('button',{name:'🌐 Web UI',exact:true});
|
||||
for (const [mode,args] of [
|
||||
['newtab',['/mounted/prompt_manager/workspace#/library','_blank']],
|
||||
['popup',['/mounted/prompt_manager/workspace#/library','_blank','width=1200,height=800,scrollbars=yes,resizable=yes']],
|
||||
]) {
|
||||
await page.evaluate(value=>{node.properties.webuiDisplayMode=value;},mode);
|
||||
await webUi.click();
|
||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(args);
|
||||
}
|
||||
});
|
||||
|
||||
test('Classic Web UI reports a blocked popup and keeps the canvas open (#525)',async({page})=>{
|
||||
const before=page.url();
|
||||
await page.evaluate(()=>{window.open=()=>null;});
|
||||
await page.getByRole('button',{name:'🌐 Web UI',exact:true}).click();
|
||||
await expect(page.getByText('Allow popups to open Web UI, then try again.')).toBeVisible();
|
||||
expect(page.url()).toBe(before);
|
||||
await expect(page.locator('.pm-classic')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Classic detaches native text synchronization on removal',async({page})=>{
|
||||
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
|
||||
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
|
||||
|
||||
@@ -111,9 +111,23 @@ async function selectTheme(page,theme) {
|
||||
}
|
||||
|
||||
/** The concept at `#<route>` with its own Theme select. */
|
||||
/**
|
||||
* The recorded layout deviations (#530), applied to the opened concept page and never to its files, so every box that
|
||||
* follows from them (wider columns, wrapped text, row heights) is compared rather than excused path by path.
|
||||
* Every other measurement still differs if production moves. workspace-layout-tags-system.spec.mjs pins the real values
|
||||
* and that the concept itself still has the old ones.
|
||||
* D182: the second `.two-col` card starts level, and a surface after a row is 18px below it. D184: no 1000px limit on System. D183: the sticky tag header's background and drawn line.
|
||||
*/
|
||||
export const CONCEPT_LAYOUT_DEVIATIONS=`.two-col > .surface + .surface { margin-top: 0; }
|
||||
.two-col + .surface { margin-top: 18px; }
|
||||
.settings-layout { max-width: none; }
|
||||
.two-col > .surface > .table-wrap thead th { background: var(--ca-surface); border-bottom-color: transparent; }`;
|
||||
export const mapConceptLayout=page=>page.addStyleTag({content:CONCEPT_LAYOUT_DEVIATIONS});
|
||||
|
||||
export async function openConcept(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||||
await mapConceptLayout(page);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
// browser that exits abnormally prints one `pm-browser-exit|` line, and a test
|
||||
// that fails on a closed or crashed target gets that log attached as
|
||||
// `browser-stderr`.
|
||||
// The one-time older-images notice (#527) shows above every workspace page and as
|
||||
// a modal on the admin page until dismissed, so every context starts with it
|
||||
// dismissed. A spec about the notice sets `test.use({ olderImagesNotice: true })`.
|
||||
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
|
||||
@@ -44,6 +47,11 @@ export const test = base.extend({
|
||||
if (crash) throw crash;
|
||||
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||
baseURL: async ({ workerServer }, use) => use(workerServer.url),
|
||||
olderImagesNotice: [false, { option: true }],
|
||||
storageState: async ({ baseURL, olderImagesNotice }, use) => use(olderImagesNotice ? undefined : {
|
||||
cookies: [],
|
||||
origins: [{ origin: new URL(baseURL).origin, localStorage: [{ name: 'pm_update_notice_dismissed', value: '3.2.0' }] }],
|
||||
}),
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
@@ -485,7 +485,7 @@ test(`${variant} respects the existing Web UI display preference`,async({page})=
|
||||
});
|
||||
await page.evaluate(()=>node._focusedSettingsReady);
|
||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
|
||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/workspace#/library','_blank']);
|
||||
});
|
||||
|
||||
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ class ResetWatch:
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
except (OSError, ValueError):
|
||||
# the stream closed; a watcher must not fail the server
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ function measureSystem(mainSelector) {
|
||||
const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect();
|
||||
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
|
||||
};
|
||||
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. This pins the
|
||||
// concept's `.surface + .surface` 18px offset, which concept.css applies inside `.two-col` too (copied, not ruled).
|
||||
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. D182 (#530)
|
||||
// levels the second `.two-col` card, on both sides through mapConceptLayout; the real values are in workspace-layout-tags-system.spec.
|
||||
const top = node => {
|
||||
const rect = node.getBoundingClientRect(), previous = node.previousElementSibling?.getBoundingClientRect();
|
||||
if (previous && rect.top >= previous.bottom) return Math.round(rect.top - previous.bottom);
|
||||
@@ -165,6 +165,8 @@ const number = value => value.toLocaleString('en-US');
|
||||
|
||||
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
||||
function mapOverviewConcept(reference, overview) {
|
||||
// D165: the discreet legacy-pages line follows the settings layout.
|
||||
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
|
||||
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
|
||||
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
|
||||
const rows = {
|
||||
@@ -257,7 +259,7 @@ const UNMAPPED_PATHS = [
|
||||
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
|
||||
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
||||
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
|
||||
].sort();
|
||||
|
||||
test('system comparison detects mutations', async ({ page, request }) => {
|
||||
@@ -683,6 +685,8 @@ async function watchedSources(request) {
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) {
|
||||
const variant = notice ? 'with' : 'without';
|
||||
test.describe(() => {
|
||||
test.use({ olderImagesNotice: notice });
|
||||
test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const server = await watchedSources(request);
|
||||
@@ -693,8 +697,11 @@ for (const theme of THEMES) for (const [width, height] of SIZES) for (const noti
|
||||
expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe(() => {
|
||||
test.use({ olderImagesNotice: true });
|
||||
test('sources comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const server = await watchedSources(request);
|
||||
@@ -737,6 +744,7 @@ test('sources comparison detects mutations', async ({ page, request }) => {
|
||||
await undoToken();
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title,
|
||||
|
||||
@@ -88,7 +88,7 @@ function measureIntegrations(mainSelector) {
|
||||
};
|
||||
}
|
||||
|
||||
const SEND_REASON = 'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.';
|
||||
const SEND_REASON = 'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.';
|
||||
const RENDERER_REASON = 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.';
|
||||
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
|
||||
const RETURN_REASON = 'Not available: sending applies the text in ComfyUI and keeps this page open.';
|
||||
|
||||
@@ -231,6 +231,9 @@ export function mapConceptReview(reference,actual) {
|
||||
const actualActions=findNode(actual.main,'div.dialog-actions');
|
||||
let cardIndex=0;
|
||||
let main={...reference.main,id:actual.main.id};
|
||||
// D188: the Resolve panel is sticky.
|
||||
const actualPanel=findNode(actual.main,'div.gallery-layout')?.children[1];
|
||||
if(actualPanel)main=editNodes(main,'div.gallery-layout',layout=>({...layout,children:layout.children.map((child,index)=>index===1?{...child,position:actualPanel.position}:child)}));
|
||||
let noticeLoss=0;
|
||||
main=editNodes(main,'div.notice',notice=>{
|
||||
const chip=notice.children.find(child=>child.tag==='span.chip');
|
||||
@@ -279,7 +282,7 @@ export function mapConceptReview(reference,actual) {
|
||||
});
|
||||
main=editNodes(main,'select',select=>{
|
||||
const twin=findNode(actual.main,'select');
|
||||
return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select;
|
||||
return select.id==='review-context'?{...select,options:twin.options}:select; // D179: no Leave unresolved option
|
||||
});
|
||||
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
|
||||
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
|
||||
|
||||
@@ -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}]);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// The one-time older-images notice in the workspace shell (#527, D166). Its dismissal is the legacy admin page's key,
|
||||
// so dismissing it in any place dismisses it everywhere.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const WORKSPACE = '/prompt_manager/workspace';
|
||||
const ADMIN = '/prompt_manager/admin';
|
||||
const NOTICE_KEY = 'pm_update_notice_dismissed';
|
||||
|
||||
const shellNotice = page => page.locator('#older-images-notice > .notice');
|
||||
const sourcesNotice = page => page.locator('#workspace-main .settings-layout > .notice');
|
||||
const dialog = page => page.locator('dialog#modal[open]');
|
||||
const adminModal = page => page.locator('#updateNoticeModal');
|
||||
const stored = page => page.evaluate(key => localStorage.getItem(key), NOTICE_KEY);
|
||||
|
||||
async function openRoute(page, route) {
|
||||
await page.goto(`${WORKSPACE}#/${route}`);
|
||||
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
|
||||
}
|
||||
|
||||
// Every spec on this worker's server shares its job list.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
});
|
||||
|
||||
test.describe('with the notice not dismissed', () => {
|
||||
test.use({ olderImagesNotice: true });
|
||||
|
||||
for (const route of ['library', 'images', 'tags', 'activity', 'system/overview']) {
|
||||
test(`shows above the ${route} page with Later and Review scan`, async ({ page }) => {
|
||||
await openRoute(page, route);
|
||||
await expect(shellNotice(page).locator('h2')).toHaveText('Bring older images into your prompt history');
|
||||
await expect(shellNotice(page).locator('.row-actions > button')).toHaveText(['Later', 'Review scan']);
|
||||
await expect(page.locator('#older-images-notice')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('Later dismisses it for good without a write or a scan', async ({ page, request }) => {
|
||||
const writes = [];
|
||||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
|
||||
await openRoute(page, 'library');
|
||||
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
await expect(page.locator('#older-images-notice')).toBeHidden();
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
expect(await stored(page)).toBe('3.2.0');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
expect(writes).toEqual([]);
|
||||
expect((await (await request.get('/prompt_manager/jobs')).json()).jobs).toEqual([]);
|
||||
});
|
||||
|
||||
test('Review scan dismisses it and opens the review of every source', async ({ page }) => {
|
||||
await openRoute(page, 'library');
|
||||
await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
|
||||
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
expect(await stored(page)).toBe('3.2.0');
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
});
|
||||
|
||||
test('Review scan keeps the notice and says so when the sources cannot be read', async ({ page }) => {
|
||||
await openRoute(page, 'library');
|
||||
await page.route('**/prompt_manager/settings', route => route.fulfill({ status: 500, json: { success: false } }));
|
||||
await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Could not read the image sources. Try again.');
|
||||
await expect(shellNotice(page)).toHaveCount(1);
|
||||
await expect(shellNotice(page).getByRole('button', { name: 'Review scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await stored(page)).toBeNull();
|
||||
});
|
||||
|
||||
test('System > Sources shows one notice, its own, and dismissing it there clears the shell', async ({ page }) => {
|
||||
await openRoute(page, 'system/overview');
|
||||
await expect(shellNotice(page)).toHaveCount(1);
|
||||
await page.goto(`${WORKSPACE}#/system/sources`);
|
||||
await expect(page.locator('#workspace-main .settings-layout > .notice:visible')).toHaveCount(1);
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
await sourcesNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
|
||||
await expect(sourcesNotice(page)).toHaveCount(0);
|
||||
await page.goto(`${WORKSPACE}#/library`);
|
||||
await expect(page.locator('#workspace-main h1').first()).toBeVisible();
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dismissing in the shell clears the copy System has already mounted', async ({ page }) => {
|
||||
await openRoute(page, 'system/sources');
|
||||
await expect(sourcesNotice(page)).toHaveCount(1);
|
||||
await page.goto(`${WORKSPACE}#/system/overview`);
|
||||
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
|
||||
await page.goto(`${WORKSPACE}#/system/sources`);
|
||||
await expect(page.locator('#workspace-main #source-list')).toBeVisible();
|
||||
await expect(sourcesNotice(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dismissing in another tab clears it here', async ({ page, context }) => {
|
||||
await openRoute(page, 'library');
|
||||
await expect(shellNotice(page)).toHaveCount(1);
|
||||
const other = await context.newPage();
|
||||
await openRoute(other, 'images');
|
||||
await shellNotice(other).getByRole('button', { name: 'Later', exact: true }).click();
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
await other.close();
|
||||
});
|
||||
|
||||
test('the admin modal still works: Later without the checkbox keeps the notice everywhere', async ({ page }) => {
|
||||
await page.goto(ADMIN);
|
||||
await expect(adminModal(page)).toBeVisible();
|
||||
await page.locator('#updateNoticeDismiss').click();
|
||||
await expect(adminModal(page)).toBeHidden();
|
||||
expect(await stored(page)).toBeNull();
|
||||
await openRoute(page, 'library');
|
||||
await expect(shellNotice(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('dismissing on the admin page dismisses it in the workspace', async ({ page }) => {
|
||||
await page.goto(ADMIN);
|
||||
await page.locator('#dismissUpdateNotice').check();
|
||||
await page.locator('#updateNoticeDismiss').click();
|
||||
await expect(adminModal(page)).toBeHidden();
|
||||
await openRoute(page, 'library');
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dismissing in the workspace dismisses it on the admin page', async ({ page }) => {
|
||||
await openRoute(page, 'library');
|
||||
await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click();
|
||||
await page.goto(ADMIN);
|
||||
await page.waitForLoadState('load');
|
||||
await expect(adminModal(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('the admin Scan still dismisses it and opens the scan modal', async ({ page }) => {
|
||||
await page.goto(ADMIN);
|
||||
await page.locator('#updateNoticeScan').click();
|
||||
await expect(adminModal(page)).toBeHidden();
|
||||
expect(await stored(page)).toBe('3.2.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('specs start with the notice dismissed, on every page', async ({ page, browser, baseURL }) => {
|
||||
await openRoute(page, 'library');
|
||||
await expect(shellNotice(page)).toHaveCount(0);
|
||||
const context = await browser.newContext({ baseURL });
|
||||
const other = await context.newPage();
|
||||
await other.goto(ADMIN);
|
||||
await other.waitForLoadState('load');
|
||||
await expect(adminModal(other)).toBeHidden();
|
||||
await context.close();
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||