Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb4ad5495 | ||
|
|
92be8770c3 | ||
|
|
7e1847955b | ||
|
|
ecb6b09813 | ||
|
|
cdb791d4a9 | ||
|
|
f924d7195d | ||
|
|
3789d85e58 | ||
|
|
fb6452934f | ||
|
|
395dc74cbe | ||
|
|
2f061904a2 | ||
|
|
f80e67c812 | ||
|
|
ab6fee3a82 | ||
|
|
f53a43152d | ||
|
|
fe63f01214 | ||
|
|
aab0fedc10 | ||
|
|
085e45e475 | ||
|
|
89572e03c6 | ||
|
|
60ce03ba4b | ||
|
|
ae3e191990 | ||
|
|
cd284432f5 | ||
|
|
c4eb487d02 |
@@ -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`).
|
||||
|
||||
|
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 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -1089,13 +1097,21 @@ class AdminRoutesMixin:
|
||||
return result
|
||||
|
||||
def _delete_one_duplicate(self, image_path, output_path):
|
||||
"""Delete one file and its thumbnail; return a failure reason or None."""
|
||||
"""Delete one file and its thumbnail; return a failure reason or None.
|
||||
|
||||
What is removed is exactly the folder's real path plus the name: a
|
||||
link is removed itself, never its target, and the preview removed is
|
||||
that name's. Both the link's place and what it leads to are checked.
|
||||
"""
|
||||
file_path = Path(image_path)
|
||||
if not file_path.is_absolute():
|
||||
file_path = output_path / file_path
|
||||
|
||||
try:
|
||||
rel_path = file_path.resolve().relative_to(output_path.resolve())
|
||||
real_root = output_path.resolve()
|
||||
rel_path = file_path.resolve().relative_to(real_root)
|
||||
exact = Path(os.path.realpath(file_path.parent)) / file_path.name
|
||||
exact_rel = exact.relative_to(real_root)
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
f"Attempted to delete file outside output directory: {image_path}"
|
||||
@@ -1104,15 +1120,19 @@ class AdminRoutesMixin:
|
||||
|
||||
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||
return "not a media file"
|
||||
if "thumbnails" in rel_path.parts:
|
||||
if "thumbnails" in rel_path.parts or "thumbnails" in exact_rel.parts:
|
||||
return "thumbnails are managed by the gallery"
|
||||
if in_trash_below_roots(file_path, [output_path]) or in_trash_folder(
|
||||
str(exact_rel)
|
||||
):
|
||||
return "it is in a trash folder" # never unlinked from a trash (#543)
|
||||
|
||||
if not file_path.is_file():
|
||||
return "file not found"
|
||||
|
||||
os.remove(file_path)
|
||||
os.remove(exact)
|
||||
self.logger.info(f"Deleted duplicate image: {image_path}")
|
||||
_remove_thumbnail(output_path.resolve(), rel_path, self.logger)
|
||||
_remove_thumbnail(real_root, exact_rel, self.logger)
|
||||
return None
|
||||
|
||||
async def get_statistics(self, request):
|
||||
|
||||
@@ -24,9 +24,9 @@ from . import thumbnail_settings
|
||||
from .. import model_gate
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import is_skipped_folder
|
||||
from ...utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||
except ImportError:
|
||||
from utils.media_folders import is_skipped_folder
|
||||
from utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -849,9 +849,12 @@ class AutotagRoutesMixin:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
return None
|
||||
for output_dir in self._get_all_output_dirs():
|
||||
roots = list(self._get_all_output_dirs())
|
||||
for output_dir in roots:
|
||||
if path_is_within(raw_path, output_dir):
|
||||
real = os.path.realpath(raw_path)
|
||||
if in_trash_below_roots(real, roots):
|
||||
return None # thrown away, not an output (#543)
|
||||
return real if os.path.isfile(real) else None
|
||||
return None
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ from pathlib import Path
|
||||
from .admin import DELETABLE_MEDIA_EXTENSIONS
|
||||
from .maintenance import DUPLICATE_FILES
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import in_trash_below_roots, is_trash_folder
|
||||
except ImportError:
|
||||
from utils.media_folders import in_trash_below_roots, is_trash_folder
|
||||
|
||||
# Keep entries one review takes; with paths of a few hundred characters the
|
||||
# list stays inside the preview body cap (DUPLICATE_PREVIEW_BODY_MAX, 1 MiB).
|
||||
MAX_KEEP = 2000
|
||||
@@ -197,6 +202,8 @@ def file_problem(
|
||||
parts = Path(os.path.relpath(lexical, base)).parts
|
||||
if "thumbnails" in parts or ".." in parts:
|
||||
return OUTSIDE_SOURCE
|
||||
if any(is_trash_folder(part) for part in parts[:-1]):
|
||||
return OUTSIDE_SOURCE # never removed from a trash folder (#543)
|
||||
if os.path.splitext(lexical)[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||
return OUTSIDE_SOURCE
|
||||
try:
|
||||
@@ -212,6 +219,8 @@ def file_problem(
|
||||
try:
|
||||
if not _lexically_inside(_canonical_path(path), [_canonical_path(root)]):
|
||||
return OUTSIDE_SOURCE # a linked folder on the way
|
||||
if in_trash_below_roots(path, [root]):
|
||||
return OUTSIDE_SOURCE # a linked folder into a trash folder
|
||||
if keeper is not None:
|
||||
same = keeper_problem(path, root, info, keeper, keeper_root)
|
||||
if same:
|
||||
|
||||
@@ -17,6 +17,7 @@ from aiohttp import web
|
||||
|
||||
from . import thumbnail_settings
|
||||
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
from .images import in_trash_below_roots
|
||||
from .images import existing_media_thumbnail, media_thumbnail_rel
|
||||
|
||||
try:
|
||||
@@ -198,6 +199,9 @@ def resolve_current_candidate(
|
||||
if namespace is None or not namespace.is_dir():
|
||||
return _unavailable("root_unavailable")
|
||||
target = _canonical(lexical)
|
||||
if in_trash_below_roots(target, [namespace]):
|
||||
# Every kind and the outputs listing come through here (#543).
|
||||
return _unavailable("missing")
|
||||
if not _authorized(target, namespace, scope):
|
||||
return _unavailable("not_authorized")
|
||||
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
@@ -248,6 +252,10 @@ def _open_current_target(
|
||||
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
|
||||
if _canonical(target) != target:
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||
if in_trash_below_roots(target, [namespace]):
|
||||
# Also at the open: the cover chooses its preview after the candidate
|
||||
# check, and a preview can be a link into a trash folder (#543).
|
||||
raise CurrentLocationUnavailable("missing")
|
||||
if not _authorized(target, namespace, scope):
|
||||
raise CurrentLocationUnavailable("not_authorized")
|
||||
suffix = target.suffix.lower()
|
||||
|
||||
@@ -66,7 +66,12 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...database.run_attribution import recorded_output_lookup
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from ...utils.media_folders import (
|
||||
in_skipped_folder,
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.json_strict import dumps_strict
|
||||
from ...utils.inspection_response import (
|
||||
@@ -80,7 +85,7 @@ try:
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
is_same_origin_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
@@ -92,7 +97,12 @@ except ImportError:
|
||||
from database.models import normalize_image_path
|
||||
from database.run_attribution import recorded_output_lookup
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from utils.media_folders import (
|
||||
in_skipped_folder,
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
from utils.parallel import map_parallel
|
||||
from utils.json_strict import dumps_strict
|
||||
from utils.inspection_response import (
|
||||
@@ -106,7 +116,7 @@ except ImportError:
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
is_same_origin_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
@@ -277,10 +287,20 @@ def _forbidden(message="Access denied"):
|
||||
return web.json_response({"success": False, "error": message}, status=403)
|
||||
|
||||
|
||||
def _media_access_error(path, allowed_dirs):
|
||||
"""Return a 403 response when *path* must not be served, else None.
|
||||
def _file_not_found():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"},
|
||||
status=404,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
Fails closed: an empty *allowed_dirs* denies everything.
|
||||
|
||||
def _media_access_error(path, allowed_dirs):
|
||||
"""The response refusing *path*, or None when it may be read.
|
||||
|
||||
Fails closed: an empty *allowed_dirs* denies everything (403). A file in a
|
||||
trash folder below its root is thrown away, not an output: a 404, as if it
|
||||
were not there (#543), also through a link or another spelling.
|
||||
"""
|
||||
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _forbidden("Only media files can be served")
|
||||
@@ -288,6 +308,8 @@ def _media_access_error(path, allowed_dirs):
|
||||
return _forbidden("No allowed image directories configured")
|
||||
if not any(_is_within(path, root) for root in allowed_dirs):
|
||||
return _forbidden()
|
||||
if in_trash_below_roots(path, allowed_dirs):
|
||||
return _file_not_found()
|
||||
return None
|
||||
|
||||
|
||||
@@ -795,12 +817,12 @@ def _reveal_not_found():
|
||||
)
|
||||
|
||||
|
||||
def _not_local():
|
||||
def _not_same_origin():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "not-local",
|
||||
"error": "Show in folder works only on the computer running ComfyUI.",
|
||||
"code": "not-same-origin",
|
||||
"error": "This page's address does not match the server's.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
@@ -979,6 +1001,8 @@ def _settled_entry(root, relative):
|
||||
return None
|
||||
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
|
||||
return None
|
||||
if in_trash_below_roots(path, [root]):
|
||||
return None # a linked folder on the way leads into a trash folder
|
||||
folder = os.fspath(root)
|
||||
for part in ("", *folders):
|
||||
folder = os.path.join(folder, part) if part else folder
|
||||
@@ -1514,7 +1538,7 @@ class ImageRoutesMixin:
|
||||
async def clear_image_favorite_route(request):
|
||||
return await self.update_image_favorite(request, favorite=False)
|
||||
|
||||
# POST, local-only: opens this computer's file manager (D38).
|
||||
# POST, same-origin: opens the server computer's file manager (D38).
|
||||
@routes.post("/prompt_manager/images/reveal")
|
||||
async def reveal_image_route(request):
|
||||
return await self.reveal_image(request)
|
||||
@@ -2761,6 +2785,9 @@ class ImageRoutesMixin:
|
||||
if _has_traversal(filepath):
|
||||
return _forbidden()
|
||||
suffix = Path(filepath).suffix.lower()
|
||||
if in_trash_folder(filepath):
|
||||
# Before any file access, so no preview is made for it (#543).
|
||||
return _file_not_found()
|
||||
if suffix not in SERVABLE_EXTENSIONS and not (
|
||||
# A JPEG XL preview is served from thumbnails/ only (#317).
|
||||
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
@@ -2784,6 +2811,8 @@ class ImageRoutesMixin:
|
||||
# a fall-through to the next root.
|
||||
if not _is_within(image_path, output_path):
|
||||
return _forbidden()
|
||||
if in_trash_below_roots(image_path, output_dirs):
|
||||
return _file_not_found()
|
||||
if image_path.is_file():
|
||||
return self._file_response(image_path)
|
||||
|
||||
@@ -2835,8 +2864,9 @@ class ImageRoutesMixin:
|
||||
if _is_thumbnail_cache_path(filepath):
|
||||
return _forbidden()
|
||||
image_path = root / filepath
|
||||
if not _is_within(image_path, root):
|
||||
return _forbidden()
|
||||
denied = _media_access_error(image_path, [root])
|
||||
if denied is not None:
|
||||
return denied
|
||||
if not image_path.is_file():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
@@ -3682,12 +3712,12 @@ class ImageRoutesMixin:
|
||||
async def reveal_image(self, request):
|
||||
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
|
||||
|
||||
Local-only (a same-origin browser request from a loopback client, no
|
||||
forwarding header) and JSON-only: the browser must run on the
|
||||
computer whose file manager opens. One reveal at a time (429 busy).
|
||||
Same-origin and JSON-only, directly or through a proxy: the folder
|
||||
opens on the computer running ComfyUI, wherever the browser is
|
||||
(maintainer decision, #533). One reveal at a time (429 busy).
|
||||
"""
|
||||
if not is_local_request(request.remote, request.headers, request.scheme):
|
||||
return _not_local()
|
||||
if not is_same_origin_request(request.headers, request.scheme):
|
||||
return _not_same_origin()
|
||||
if request.content_type != "application/json":
|
||||
# A non-JSON POST is a CORS "simple" request with no preflight.
|
||||
return web.json_response(
|
||||
@@ -3765,7 +3795,9 @@ class ImageRoutesMixin:
|
||||
{
|
||||
"success": False,
|
||||
"code": "unavailable",
|
||||
"error": "No file manager is available on this computer.",
|
||||
"error": (
|
||||
"No file manager is available on the computer running ComfyUI."
|
||||
),
|
||||
},
|
||||
status=501,
|
||||
)
|
||||
|
||||
@@ -9,13 +9,15 @@ by the caller.
|
||||
import os
|
||||
import re
|
||||
|
||||
from .images import _forbidden, _has_traversal
|
||||
from .images import _file_not_found, _forbidden, _has_traversal
|
||||
from .prompts import bad_request
|
||||
|
||||
try:
|
||||
from ...utils.media_capture import CAPTURE_EXTENSIONS
|
||||
from ...utils.media_folders import in_trash_folder
|
||||
except ImportError:
|
||||
from utils.media_capture import CAPTURE_EXTENSIONS
|
||||
from utils.media_folders import in_trash_folder
|
||||
|
||||
MAX_TARGET_PATH = 1024
|
||||
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
|
||||
@@ -29,7 +31,9 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
|
||||
Only the listing identity is accepted: an int root index, the root's
|
||||
64-hex scope and a bounded root-relative POSIX media path, never an
|
||||
absolute, drive-qualified, backslash or ``..`` path (a 400 for a malformed
|
||||
shape, a 403 for an unsafe or non-media path, carrying *media_message*).
|
||||
shape, a 403 for an unsafe or non-media path, carrying *media_message*, a
|
||||
404 for a path in a trash folder, #543). A link or a short-name spelling
|
||||
is refused later by the capture store's alias check.
|
||||
"""
|
||||
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
|
||||
return None, bad_request("target must name root_index, root_scope and path")
|
||||
@@ -51,4 +55,6 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
|
||||
return None, _forbidden()
|
||||
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
|
||||
return None, _forbidden(media_message)
|
||||
if in_trash_folder(relative):
|
||||
return None, _file_not_found()
|
||||
return (index, scope, relative), None
|
||||
|
||||
@@ -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):
|
||||
@@ -835,7 +840,7 @@ class AutoThumbnailMixin:
|
||||
if not folded.startswith(base) or not _is_within(path, root):
|
||||
continue
|
||||
rel = Path(os.path.relpath(path, root))
|
||||
if "thumbnails" in rel.parts[:-1]:
|
||||
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
|
||||
return None
|
||||
dst = root / media_thumbnail_rel(rel)
|
||||
if not _is_within(root / "thumbnails", root) or not _is_within(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "promptmanager"
|
||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||
version = "3.3.1"
|
||||
version = "3.4.0"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// #531 (D186-D189): the selection bars stay in view, a Back to top chip follows the page, and the side panels stay with
|
||||
// you. Beyond the concept; the concept itself is unchanged.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startAndAwaitJob } from './job-end.mjs';
|
||||
|
||||
const IMAGES = '/prompt_manager/workspace#/images';
|
||||
const LIBRARY = '/prompt_manager/workspace#/library';
|
||||
const REVIEW = '/prompt_manager/workspace#/review';
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
const imageCards = page => page.locator('#image-grid article[data-image-key]');
|
||||
const promptCards = page => page.locator('article[data-prompt-id]');
|
||||
const imageBar = page => page.locator('#bulkbar > .bulkbar');
|
||||
const libraryBar = page => page.locator('#library-bulkbar > .bulkbar');
|
||||
const chip = page => page.getByRole('button', { name: 'Back to top', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const reviewPanel = page => page.getByRole('region', { name: 'Resolve the association', exact: true });
|
||||
const reviewCards = page => page.locator('.review-queue article.media-card');
|
||||
|
||||
const box = locator => locator.evaluate(node => {
|
||||
const { top, bottom, left, right } = node.getBoundingClientRect();
|
||||
return { top, bottom, left, right };
|
||||
});
|
||||
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||
/** Scroll the window to `y` and wait for the frame that applied it. */
|
||||
async function scrollToY(page, y) {
|
||||
await page.evaluate(target => new Promise(resolve => {
|
||||
scrollTo({ top: target, behavior: 'instant' });
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}), y);
|
||||
}
|
||||
/** The first card wholly below `below` px, by its index in the list. */
|
||||
async function cardBelow(cards, below) {
|
||||
const count = await cards.count();
|
||||
for (let index = 0; index < count; index++) {
|
||||
const card = cards.nth(index);
|
||||
if ((await box(card)).top <= below) continue;
|
||||
// By its key, not its position: the mounted window moves as the page does.
|
||||
const key = await card.evaluate(node => ['imageKey', 'promptId', 'reviewId'].map(name => [name, node.dataset[name]]).find(([, value]) => value));
|
||||
const attribute = key[0].replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
return cards.page().locator(`article[data-${attribute}=${JSON.stringify(key[1])}]`);
|
||||
}
|
||||
throw new Error(`no card below ${below}`);
|
||||
}
|
||||
async function openImages(page, request, extra = 100) {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: extra } })).toBeOK();
|
||||
await page.goto(IMAGES);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
}
|
||||
async function openLibrary(page, request, extra = 40) {
|
||||
await expect(await request.post('/__test__/reset', { data: { native_library: true, extra_prompts: extra } })).toBeOK();
|
||||
await page.goto(LIBRARY);
|
||||
await expect(promptCards(page).first()).toBeVisible();
|
||||
}
|
||||
async function openReview(page, request, items = 30) {
|
||||
await expect(await request.post('/__test__/review/reset', { data: { items } })).toBeOK();
|
||||
await page.goto(REVIEW);
|
||||
await expect(reviewPanel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
|
||||
}
|
||||
const inViewport = (rect, page) => rect.top >= 0 && rect.bottom <= page.viewportSize().height;
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => { await startCoverage(page, browserName); });
|
||||
test.afterEach(async ({ page, browserName }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
|
||||
|
||||
for (const viewport of [null, PHONE]) {
|
||||
const label = viewport ? ' at 390px' : '';
|
||||
test(`Images: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
const card = await cardBelow(imageCards(page), 300);
|
||||
const before = (await box(card)).top;
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page).locator('strong')).toHaveText('1 selected');
|
||||
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
|
||||
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
|
||||
// Keep scrolling and selecting: the bar stays; Clear returns the card to its place too.
|
||||
await scrollToY(page, 1900);
|
||||
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
|
||||
const later = await cardBelow(imageCards(page), 300);
|
||||
await later.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page).locator('strong')).toHaveText('2 selected');
|
||||
const held = (await box(later)).top;
|
||||
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await expect(imageBar(page)).toHaveCount(0);
|
||||
expect(Math.abs((await box(later)).top - held)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test(`Library: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
await openLibrary(page, request);
|
||||
await scrollToY(page, 2000);
|
||||
await expect(promptCards(page).first()).toBeAttached();
|
||||
const card = await cardBelow(promptCards(page), 250);
|
||||
const before = (await box(card)).top;
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(libraryBar(page).locator('strong')).toHaveText('1 selected');
|
||||
expect(inViewport(await box(libraryBar(page)), page)).toBe(true);
|
||||
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
|
||||
expect(await card.isVisible()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('the Images panel clears the stuck bar and a focused card is not left under it', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await (await cardBelow(imageCards(page), 300)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
await expect.poll(async () => (await box(page.locator('#image-inspector'))).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
|
||||
expect(overlaps(await box(page.locator('#image-inspector')), await box(imageBar(page)))).toBe(false);
|
||||
// A control scrolled to by focus lands below the bar; the bar's own controls do not move the page.
|
||||
const y = await page.evaluate(() => scrollY);
|
||||
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
|
||||
expect(await page.evaluate(() => scrollY)).toBe(y);
|
||||
const above = imageCards(page).first().locator('.image-overlays input[type="checkbox"]');
|
||||
await above.focus();
|
||||
expect((await box(above)).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
|
||||
});
|
||||
|
||||
test('Back to top shows off the top of a page, goes to the top and hands focus to the page', async ({ page, request }) => {
|
||||
await openImages(page, request);
|
||||
await expect(chip(page)).toBeHidden();
|
||||
await scrollToY(page, 1500);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
const loaded = await page.evaluate(() => document.querySelector('#result-count').textContent);
|
||||
await chip(page).click();
|
||||
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
|
||||
await expect(chip(page)).toBeHidden();
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
expect(await page.evaluate(() => document.querySelector('#result-count').textContent)).toBe(loaded);
|
||||
await expect(imageCards(page).first()).toBeInViewport();
|
||||
});
|
||||
|
||||
test('Back to top on the virtualized Library lands on the first card and stays there', async ({ page, request }) => {
|
||||
await openLibrary(page, request, 200);
|
||||
// Past 120 loaded prompts the shelf mounts only a window of them (each pass reaches the next page's trigger).
|
||||
await expect.poll(async () => {
|
||||
await page.evaluate(() => scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }));
|
||||
return page.locator('article[data-index="0"]').count();
|
||||
}, { timeout: 30000 }).toBe(0);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
await chip(page).click();
|
||||
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
|
||||
await expect.poll(async () => Number(await promptCards(page).first().getAttribute('data-index'))).toBe(0);
|
||||
await expect(promptCards(page).first()).toBeInViewport();
|
||||
expect(await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(scrollY)))))).toBe(0);
|
||||
});
|
||||
|
||||
test('Back to top jumps, under reduced motion and otherwise', async ({ page, request }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await openImages(page, request, 20);
|
||||
await scrollToY(page, 600);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
const after = await chip(page).evaluate(node => { node.click(); return scrollY; });
|
||||
expect(after).toBe(0);
|
||||
});
|
||||
|
||||
for (const viewport of [{ width: 1280, height: 720 }, PHONE]) {
|
||||
test(`Back to top moves clear of a toast and of the bar at ${viewport.width}px`, async ({ page, request }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
expect(overlaps(await box(chip(page)), await box(imageBar(page)))).toBe(false);
|
||||
const alone = await box(chip(page));
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('workspace-toast').textContent = 'A much longer message than most, one that keeps going, so that it wraps to more than two lines on the narrowest screen a phone will give this page to show it on, and then some more words again.';
|
||||
});
|
||||
await expect(toast(page)).not.toBeEmpty();
|
||||
await expect.poll(async () => overlaps(await box(chip(page)), await box(toast(page)))).toBe(false);
|
||||
expect((await box(chip(page))).bottom).toBeLessThanOrEqual((await box(toast(page))).top);
|
||||
await expect(chip(page)).toBeInViewport();
|
||||
await page.evaluate(() => { document.getElementById('workspace-toast').textContent = ''; });
|
||||
await expect.poll(async () => Math.round((await box(chip(page))).bottom)).toBe(Math.round(alone.bottom));
|
||||
});
|
||||
}
|
||||
|
||||
test('a stuck bar at 390px is one row that keeps every action reachable', async ({ page, request }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
expect((await box(imageBar(page))).bottom - (await box(imageBar(page))).top).toBeLessThan(80);
|
||||
const clear = imageBar(page).getByRole('button', { name: 'Clear', exact: true });
|
||||
await clear.scrollIntoViewIfNeeded();
|
||||
await expect(clear).toBeInViewport();
|
||||
await clear.click();
|
||||
await expect(imageBar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the Needs review panel stays with you, and its long content scrolls inside it', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 500 });
|
||||
await openReview(page, request);
|
||||
await scrollToY(page, 700);
|
||||
await expect(reviewPanel(page)).toBeInViewport();
|
||||
const rect = await box(reviewPanel(page));
|
||||
expect(rect.top).toBeGreaterThanOrEqual(0);
|
||||
expect(rect.bottom).toBeLessThanOrEqual(500);
|
||||
const card = await cardBelow(reviewCards(page), 100);
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
const name = await card.locator('.media-info').first().textContent();
|
||||
expect(name).toBeTruthy();
|
||||
await expect(reviewPanel(page)).toBeInViewport();
|
||||
expect((await box(reviewPanel(page))).top).toBe(rect.top);
|
||||
});
|
||||
|
||||
test('at 390px Info on a lower review card brings the panel into view', async ({ page, request }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await openReview(page, request);
|
||||
await expect(reviewPanel(page)).not.toBeInViewport();
|
||||
const card = reviewCards(page).nth(3);
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
await expect(reviewPanel(page)).toBeInViewport();
|
||||
});
|
||||
|
||||
// D189: at one column the Images panel follows the whole list: Info goes there, and a batch doesn't push it away.
|
||||
test('at 390px Info on an image brings its panel into view and loading waits for the next move', async ({ page, request }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await openImages(page, request);
|
||||
expect(await page.evaluate(() => scrollY)).toBe(0);
|
||||
await expect(page.locator('#image-inspector')).not.toBeInViewport();
|
||||
let reads = 0;
|
||||
page.on('request', item => { if (new URL(item.url()).pathname.endsWith('/images/recent')) reads++; });
|
||||
const card = imageCards(page).nth(3);
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
await expect(page.locator('#image-inspector')).toBeInViewport();
|
||||
await expect(page.locator('#viewer-dialog')).toBeHidden();
|
||||
const held = (await box(page.locator('#image-inspector'))).top;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect((await box(page.locator('#image-inspector'))).top).toBe(held);
|
||||
expect(reads).toBe(0);
|
||||
// The next move releases it: loading resumes.
|
||||
const next = page.waitForRequest(item => new URL(item.url()).pathname.includes('/images'));
|
||||
await page.mouse.wheel(0, 40);
|
||||
await next;
|
||||
});
|
||||
|
||||
// Review's review-state load more is a button, so Library's anchor stays honest through a compensated bar.
|
||||
test('a Library selection does not jump after a pending height correction', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await openLibrary(page, request);
|
||||
await scrollToY(page, 3000);
|
||||
const card = await cardBelow(promptCards(page), 250);
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(libraryBar(page)).toBeVisible();
|
||||
const before = (await box(card)).top;
|
||||
await page.setViewportSize({ width: 1300, height: 800 });
|
||||
await expect(card).toBeAttached();
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await expect.poll(async () => Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
// #531: after a bulk action on a bar, the page shows the refreshed list from the top.
|
||||
async function selectFarDownImages(page, request) {
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
}
|
||||
const atTop = page => expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
|
||||
const applyDialog = async (page, title, value) => {
|
||||
const dialog = page.getByRole('dialog', { name: title, exact: true });
|
||||
await dialog.locator('#action-input').fill(value);
|
||||
await dialog.getByRole('button', { name: /^(Apply|Add)/ }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
};
|
||||
test('Images: Set category returns to the top of the refreshed list', async ({ page, request }) => {
|
||||
await selectFarDownImages(page, request);
|
||||
const read = page.waitForRequest(item => item.url().includes('/prompt_manager/images/'));
|
||||
await imageBar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await applyDialog(page, 'Categorize selected images', 'Studio');
|
||||
await read;
|
||||
await atTop(page);
|
||||
await expect(imageBar(page)).toHaveCount(0);
|
||||
await expect(imageCards(page).first()).toBeInViewport();
|
||||
});
|
||||
test('Images: Add tags returns to the top of the refreshed list when its job ends', async ({ page, request }) => {
|
||||
await selectFarDownImages(page, request);
|
||||
await imageBar(page).getByRole('button', { name: 'Add tags', exact: true }).click();
|
||||
await applyDialog(page, 'Tag selected images', 'sticky-tag');
|
||||
await atTop(page);
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
});
|
||||
test('Library: Set category and Add tags return to the top of the reloaded shelf', async ({ page, request }) => {
|
||||
await openLibrary(page, request);
|
||||
for (const [button, title, value] of [['Set category', 'Categorize selected prompts', 'Studio'], ['Add tags', 'Tag selected prompts', 'sticky']]) {
|
||||
await scrollToY(page, 2000);
|
||||
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await libraryBar(page).getByRole('button', { name: button, exact: true }).click();
|
||||
await applyDialog(page, title, value);
|
||||
await atTop(page);
|
||||
await expect(libraryBar(page)).toHaveCount(0);
|
||||
await expect(promptCards(page).first()).toBeInViewport();
|
||||
}
|
||||
});
|
||||
test('Images: Delete… returns to the top of the reloaded list', async ({ page, request }) => {
|
||||
await selectFarDownImages(page, request);
|
||||
await imageBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const dialog = page.locator('dialog[open]');
|
||||
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
|
||||
await startAndAwaitJob(page, () => dialog.locator('.dialog-actions button.danger').click());
|
||||
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
||||
await atTop(page);
|
||||
await expect(page.locator('#bulkbar')).toBeEmpty();
|
||||
});
|
||||
test('Library: Delete… returns to the top of the reloaded shelf', async ({ page, request }) => {
|
||||
await openLibrary(page, request);
|
||||
await scrollToY(page, 2000);
|
||||
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await libraryBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await page.locator('dialog[open] .dialog-actions button.danger').click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await atTop(page);
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ test('send destination offers Ask each time; unsupported options disabled with t
|
||||
.toEqual([['Ask each time', false], ['Selected Prompt Manager node', true], ['Create a Prompt Manager (+/-) node', true]]);
|
||||
await expect(send.locator('select')).toHaveValue('Ask each time');
|
||||
await expect(send.locator('p.small')).toHaveText(
|
||||
'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.');
|
||||
'Sending asks when there is more than one ComfyUI window or node, so “Ask each time” is the only option.');
|
||||
const renderer = settingOf(page, 'Host compatibility');
|
||||
expect(await renderer.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
|
||||
.toEqual([['Follow host renderer', false], ['Classic', true], ['Nodes 2.0', true]]);
|
||||
|
||||
@@ -41,6 +41,9 @@ async function serverJson(request, url) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// `openSources` dismisses the notice itself unless a test asks for it.
|
||||
test.use({ olderImagesNotice: true });
|
||||
|
||||
/** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */
|
||||
async function openSources(page, { notice = false } = {}) {
|
||||
if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
|
||||
|
||||
@@ -897,6 +897,8 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
|
||||
// System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed
|
||||
// `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs
|
||||
// checks the rest of each row.
|
||||
test.describe(() => {
|
||||
test.use({ olderImagesNotice: true });
|
||||
test('every covered legacy System capability has its native control', async ({ page, request }) => {
|
||||
// Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is
|
||||
// missing here.
|
||||
@@ -907,6 +909,7 @@ test('every covered legacy System capability has its native control', async ({ p
|
||||
await request.post('/__test__/tagging/reset', { data: {} });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function findControls(page) {
|
||||
const covered = readCoverageTable().filter(row => row.Status === 'covered')
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// #532 (TESTING.md D170): ← and → move through the viewer's files wherever focus is, except where the arrows belong to
|
||||
// what has focus; ⌘ beside the view switch lists the shortcuts in an overlay that closes on its own.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
|
||||
const CARDS = '.gallery-body > .image-grid > .media-card';
|
||||
const viewer = page => page.locator('dialog#viewer-dialog[open]');
|
||||
const position = page => viewer(page).locator('.media-position');
|
||||
const keysButton = page => viewer(page).locator('.dialog-header > button#viewer-keys');
|
||||
const ALL_KEYS = ['←', '→', 'Space', '+', '-', '0', 'R', 'H', 'F', 'I', '?', 'Esc', 'D', 'Delete'];
|
||||
const overlay = page => page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }, testInfo) => {
|
||||
await startCoverage(page, browserName);
|
||||
testInfo.manifest = await seedConcept(request);
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
|
||||
|
||||
async function openImages(page, width = 1600) {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
|
||||
await expect(position(page)).toContainText('1 of ');
|
||||
}
|
||||
async function movesBothWays(page) {
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(position(page)).toContainText('2 of ');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(position(page)).toContainText('1 of ');
|
||||
}
|
||||
async function staysPut(page) {
|
||||
const before = await position(page).textContent();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expect(position(page)).toHaveText(before);
|
||||
}
|
||||
|
||||
test('the arrows move through the files as soon as the viewer opens, with focus on ✕', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await expect(viewer(page).locator('#viewer-close')).toBeFocused();
|
||||
await movesBothWays(page);
|
||||
});
|
||||
|
||||
test('the arrows keep working from every header control and when focus falls to the page', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await viewer(page).locator('#viewer-mode').click();
|
||||
await movesBothWays(page);
|
||||
await viewer(page).locator('#viewer-mode').click();
|
||||
// Header text is not focusable: the click leaves focus outside any control.
|
||||
await viewer(page).locator('.dialog-header p').click();
|
||||
await movesBothWays(page);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await movesBothWays(page);
|
||||
});
|
||||
|
||||
test('the arrows move from a details button but stay with focused text and with modifier keys', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await viewer(page).locator('#viewer-details button').first().focus();
|
||||
await movesBothWays(page);
|
||||
await viewer(page).locator('#viewer-details p.prompt-text').first().focus();
|
||||
await staysPut(page);
|
||||
await viewer(page).locator('#viewer-close').focus();
|
||||
const before = await position(page).textContent();
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expect(position(page)).toHaveText(before);
|
||||
});
|
||||
|
||||
test('⌘ lists the shortcuts; Esc closes only the overlay and the arrows wait for it', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await expect(keysButton(page)).toHaveAccessibleName('Keyboard shortcuts');
|
||||
await expect(keysButton(page)).toHaveAttribute('title', 'Keyboard shortcuts');
|
||||
await expect(keysButton(page).locator('svg.icon')).toHaveCount(1);
|
||||
await expect(page.locator('#viewer-keys-dialog')).toHaveCount(0);
|
||||
await keysButton(page).click();
|
||||
await expect(overlay(page)).toBeVisible();
|
||||
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
|
||||
// A small dialog sized to its content, its title inset from the edge.
|
||||
const box = await overlay(page).evaluate(node => {
|
||||
const frame = node.getBoundingClientRect(), title = node.querySelector('h2').getBoundingClientRect();
|
||||
return { height: frame.height, inset: title.left - frame.left };
|
||||
});
|
||||
expect(box.height).toBeLessThan(700);
|
||||
expect(box.inset).toBeGreaterThan(8);
|
||||
await staysPut(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(overlay(page)).toHaveCount(0);
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(keysButton(page)).toBeFocused();
|
||||
await movesBothWays(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the overlay × closes only the overlay; closing the viewer never leaves one behind', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await keysButton(page).click();
|
||||
await overlay(page).getByRole('button', { name: 'Close keyboard shortcuts', exact: true }).click();
|
||||
await expect(overlay(page)).toHaveCount(0);
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(keysButton(page)).toBeFocused();
|
||||
await keysButton(page).click();
|
||||
await expect(overlay(page)).toBeVisible();
|
||||
await page.evaluate(() => { location.hash = '/overview'; });
|
||||
await expect(page.locator('#viewer-dialog,#viewer-keys-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('opening the shortcuts stops the slideshow', async ({ page }) => {
|
||||
await openImages(page);
|
||||
const play = viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
|
||||
await play.click();
|
||||
await expect(play).toHaveAttribute('aria-label', 'Pause slideshow');
|
||||
await keysButton(page).click();
|
||||
await expect(overlay(page)).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(play).toHaveAttribute('aria-label', 'Play slideshow');
|
||||
});
|
||||
|
||||
test('the shortcuts button hides at phone width, like the view switch', async ({ page }) => {
|
||||
await openImages(page, 600);
|
||||
await expect(keysButton(page)).toBeHidden();
|
||||
await movesBothWays(page);
|
||||
});
|
||||
|
||||
// ── #532: the toolbar's actions on single keys. Each key clicks the toolbar's own control, so the two cannot disagree.
|
||||
const stage = page => viewer(page).locator('#viewer-stage');
|
||||
const image = page => viewer(page).locator('.viewer-canvas img');
|
||||
const control = (page, name) => viewer(page).getByRole('button', { name, exact: true });
|
||||
const transform = page => image(page).evaluate(node => node.style.transform + '|' + node.style.width);
|
||||
const playing = page => viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
|
||||
const review = page => page.locator('dialog#modal[open]');
|
||||
/** A key event a browser would keep for itself, or that repeats, sent straight to what has focus. */
|
||||
const synth = (page, key, init = {}) => page.evaluate(([key, init]) => {
|
||||
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
|
||||
}, [key, init]);
|
||||
async function ready(page) {
|
||||
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'false');
|
||||
// Zoom opens as the image is viewed; the viewer's first fit to the stage follows a frame later.
|
||||
let previous;
|
||||
await expect.poll(async () => {
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const settled = await transform(page);
|
||||
const same = settled === previous;
|
||||
previous = settled;
|
||||
return same;
|
||||
}).toBe(true);
|
||||
await stage(page).focus();
|
||||
}
|
||||
/** Nothing the keys can change has changed. */
|
||||
const snapshot = async page => ({ position: await position(page).textContent(), transform: await transform(page),
|
||||
play: await playing(page).getAttribute('aria-label'), classes: await viewer(page).getAttribute('class'),
|
||||
details: await viewer(page).locator('#viewer-details').isVisible(), modals: await page.locator('dialog[open]').count() });
|
||||
|
||||
test('+ - 0 zoom and fit, R rotates, H flips', async ({ page }) => {
|
||||
await openImages(page);await ready(page);
|
||||
const fit = await transform(page);
|
||||
await page.keyboard.press('+');
|
||||
await expect.poll(() => transform(page)).not.toBe(fit);
|
||||
const zoomed = await transform(page);
|
||||
await page.keyboard.press('-');
|
||||
await expect.poll(() => transform(page)).not.toBe(zoomed);
|
||||
await page.keyboard.press('+');await page.keyboard.press('0');
|
||||
await expect.poll(() => transform(page)).toBe(fit);
|
||||
await page.keyboard.press('r');
|
||||
await expect.poll(() => transform(page)).toContain('rotate(90deg)');
|
||||
await page.keyboard.press('Shift+R');
|
||||
await expect.poll(() => transform(page)).toContain('rotate(180deg)');
|
||||
await page.keyboard.press('h');
|
||||
await expect.poll(() => transform(page)).toContain('scaleX(-1)');
|
||||
});
|
||||
|
||||
test('Space plays and pauses on the stage, and activates a focused button instead', async ({ page }) => {
|
||||
await openImages(page);await ready(page);
|
||||
await page.keyboard.press('Space');
|
||||
await expect(playing(page)).toHaveAttribute('aria-label', 'Pause slideshow');
|
||||
await page.keyboard.press('Space');
|
||||
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
|
||||
await control(page, 'Next media').focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(position(page)).toContainText('2 of ');
|
||||
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
|
||||
});
|
||||
|
||||
test('F switches the view, I shows and hides the details the toggle offers', async ({ page }) => {
|
||||
await openImages(page);await ready(page);
|
||||
const details = viewer(page).locator('#viewer-details');
|
||||
// Centered without the collapse setting: the toolbar has no toggle, so I does nothing.
|
||||
await page.keyboard.press('i');
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expect(details).toBeVisible();
|
||||
await page.keyboard.press('f');
|
||||
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
|
||||
await page.keyboard.press('i');
|
||||
await expect(details).toBeHidden();
|
||||
await page.keyboard.press('i');
|
||||
await expect(details).toBeVisible();
|
||||
await page.keyboard.press('f');
|
||||
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
|
||||
});
|
||||
|
||||
test('F does nothing where the view switch is hidden; ? still opens the shortcuts, which fit at 390 px', async ({ page }) => {
|
||||
await openImages(page, 390);await ready(page);
|
||||
await page.keyboard.press('f');
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
|
||||
await page.keyboard.press('?');
|
||||
await expect(overlay(page)).toBeVisible();
|
||||
const fit = await overlay(page).evaluate(node => {
|
||||
const box = node.getBoundingClientRect();
|
||||
return { inside: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
|
||||
noSideScroll: node.scrollWidth <= node.clientWidth };
|
||||
});
|
||||
expect(fit).toEqual({ inside: true, noSideScroll: true });
|
||||
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
|
||||
await overlay(page).locator('dt').last().scrollIntoViewIfNeeded();
|
||||
await expect(overlay(page).locator('dt').last()).toBeInViewport();
|
||||
});
|
||||
|
||||
test('D downloads the original', async ({ page }) => {
|
||||
await openImages(page);await ready(page);
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), page.keyboard.press('d')]);
|
||||
expect(download.suggestedFilename()).toMatch(/\.(png|jpe?g|webp)$/);
|
||||
});
|
||||
|
||||
test('Delete opens the review and never deletes; every viewer key waits while the review is open', async ({ page }) => {
|
||||
const writes = [];
|
||||
page.on('request', request => { if (request.method() !== 'GET' && !request.url().includes('/__test__/')) writes.push(request.url()); });
|
||||
await openImages(page);await ready(page);
|
||||
const before = await snapshot(page);
|
||||
const downloads = [];page.on('download', download => downloads.push(download));
|
||||
writes.length = 0;
|
||||
await page.keyboard.press('Delete');
|
||||
await expect(review(page)).toBeVisible();
|
||||
// The review reads its preview; repeating the key must not ask again.
|
||||
await page.waitForLoadState('networkidle');
|
||||
const opened = writes.length;
|
||||
for (const key of ['Delete', 'r', 'f', 'i', 'd', '?']) await page.keyboard.press(key);
|
||||
// With focus fallen to the page, too.
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
for (const key of ['Delete', 'ArrowRight', 'r', ' ']) await page.keyboard.press(key === ' ' ? 'Space' : key);
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(writes.length).toBe(opened);
|
||||
// Inside the review the arrows and Space are its own: they may choose a mode, which only reads a new preview.
|
||||
await review(page).getByRole('radio').first().focus();
|
||||
for (const key of ['ArrowRight', 'Space']) await page.keyboard.press(key);
|
||||
await expect(overlay(page)).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(review(page)).toHaveCount(0);
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
expect(writes.filter(url => /delete|trash/i.test(url) && !url.endsWith('/delete/preview'))).toEqual([]);
|
||||
expect(await snapshot(page)).toEqual(before);
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
|
||||
test('the keys do nothing with Ctrl, Cmd or Alt held, on a repeat, or while the overlay is open', async ({ page }) => {
|
||||
await openImages(page);await ready(page);
|
||||
const before = await snapshot(page);
|
||||
const downloads = [];page.on('download', download => downloads.push(download));
|
||||
for (const modifier of ['ctrlKey', 'metaKey', 'altKey'])
|
||||
for (const key of ['+', '-', '0', 'r', 'h', ' ', 'f', 'd', 'Delete', '?']) await synth(page, key, { [modifier]: true });
|
||||
for (const key of [' ', 'r', 'h', 'f', 'd', 'Delete', '?']) await synth(page, key, { repeat: true });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
expect(await snapshot(page)).toEqual(before);
|
||||
expect(downloads).toEqual([]);
|
||||
await page.keyboard.press('?');
|
||||
await expect(overlay(page)).toBeVisible();
|
||||
for (const key of ['r', 'h', 'f', 'i', '0', 'Delete']) await page.keyboard.press(key);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(overlay(page)).toHaveCount(0);
|
||||
expect(await snapshot(page)).toEqual(before);
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('the keys stay out of a form field, raw metadata and a disclosure', async ({ page }) => {
|
||||
await openImages(page);await ready(page);
|
||||
const before = await snapshot(page);
|
||||
await viewer(page).locator('#viewer-details').evaluate(node => {
|
||||
const field = document.createElement('input');field.id = 'probe';node.append(field);
|
||||
});
|
||||
await viewer(page).locator('#probe').focus();
|
||||
await page.keyboard.type('rhfid0+-? ');
|
||||
await expect(viewer(page).locator('#probe')).toHaveValue('rhfid0+-? ');
|
||||
await viewer(page).locator('#probe').evaluate(node => node.remove());
|
||||
const summary = viewer(page).getByText('Workflow & raw metadata', { exact: true });
|
||||
await summary.focus();
|
||||
for (const key of ['r', 'f', '0']) await page.keyboard.press(key);
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
expect(await snapshot(page)).toEqual(before);
|
||||
});
|
||||
|
||||
test('on a video the image keys and Space do nothing, as the toolbar disables them; a focused video keeps its keys', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto('/prompt_manager/workspace#/images');
|
||||
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
|
||||
const video = viewer(page).locator('video');
|
||||
await expect(video).toHaveJSProperty('controls', true);
|
||||
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'true');
|
||||
const errors = [];page.on('pageerror', error => errors.push(error.message));
|
||||
await stage(page).focus();
|
||||
for (const key of ['+', '-', '0', 'r', 'h', 'Space']) await page.keyboard.press(key);
|
||||
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
|
||||
await expect(viewer(page).locator('.viewer-canvas')).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
await video.focus();
|
||||
await page.keyboard.press('f');
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const frame = page => viewer(page).evaluate(node => {
|
||||
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
|
||||
await openImages(page);
|
||||
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
|
||||
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
|
||||
expect(header).toEqual(['div', 'viewer-keys', 'viewer-mode', 'viewer-close']);
|
||||
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
|
||||
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
|
||||
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {mapConceptLayout} from './concept-inventory.mjs';
|
||||
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs';
|
||||
@@ -134,7 +135,9 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
|
||||
await rows(page).last().getByRole('button',{name:'Rename / merge',exact:true}).evaluate(button=>button.focus({preventScroll:true}));
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(loadMore(page)).toBeFocused();
|
||||
// Focus scrolled the end of the list into view, which appends the next page.
|
||||
// D183: Load more sits below the tag window, so reaching it does not move the window's end into view.
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(rows(page)).toHaveCount(200);
|
||||
await expect(loadMore(page)).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
@@ -142,11 +145,12 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
|
||||
await expect(loadMore(page)).toHaveCount(0);
|
||||
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
|
||||
// Reload at the top so restored scroll does not append before the check.
|
||||
await page.evaluate(()=>window.scrollTo(0,0));
|
||||
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
|
||||
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
|
||||
await page.reload();
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
const scroll=top=>page.evaluate(y=>new Promise(resolve=>{window.scrollTo(0,y ?? document.documentElement.scrollHeight);requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
|
||||
// D183: the end of the list arrives inside the tag window.
|
||||
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
|
||||
for(const count of [200,230]) {
|
||||
// Each new arrival of the end of the list appends one page.
|
||||
await scroll(0);await scroll(null);
|
||||
@@ -206,12 +210,12 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await page.route('**/prompt_manager/vocabulary/**',route=>{held.push(route);});
|
||||
await page.goto(route);
|
||||
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).length)).toBe(2);
|
||||
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
await expect(main(page).locator('.two-col')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>document.getElementById('workspace-main').classList.contains('vocabulary-main'))).toBe(false);
|
||||
await page.unroute('**/prompt_manager/vocabulary/**');
|
||||
@@ -775,7 +779,7 @@ const SIZES=[[1600,1100],[390,844]];
|
||||
const MOTIONS=['no-preference','reduce'];
|
||||
async function openConceptTags(page,theme,motion) {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);await mapConceptLayout(page);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('#main h1')).toHaveText('Tags & categories');
|
||||
}
|
||||
@@ -791,7 +795,7 @@ async function conceptToolbar(page,theme) {
|
||||
await page.goto('about:blank');await page.goto(`${concept}#library`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const inventory=await page.locator('#main search.toolbar').evaluate(toolbarInventoryOf);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);await mapConceptLayout(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
return inventory;
|
||||
}
|
||||
const tagToolbar=page=>page.locator('#workspace-main .two-col > section.surface:first-child > search.toolbar').evaluate(toolbarInventoryOf);
|
||||
|
||||
@@ -11,12 +11,12 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole
|
||||
const sidebarLinks = page => page.locator('aside.sidebar nav a');
|
||||
const routes = [
|
||||
['overview', 'Overview', null],
|
||||
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
|
||||
// D29: Images no longer links the legacy gallery.
|
||||
['images', 'Images', null],
|
||||
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
|
||||
// #249 E3: the shelf no longer links the legacy Library.
|
||||
['library', 'Prompt library', null],
|
||||
['tags', 'Tags & categories', null],
|
||||
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
|
||||
// #249 local Inspect: the native Inspect a file page.
|
||||
['inspect', 'Inspect a file', null],
|
||||
// #249 System S2: native System; every section native since S12 (D94 retired, asserted below).
|
||||
['system', 'System', null],
|
||||
@@ -60,6 +60,10 @@ test.afterEach(async ({ page, browserName }) => {
|
||||
|
||||
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
|
||||
const before = await (await request.get('/prompt_manager/stats')).json();
|
||||
const total = async path => (await (await request.get(`/prompt_manager/${path}`)).json()).total;
|
||||
const counts = [['Prompts', await total('library?limit=1')],
|
||||
['Images', await total('images/output?mode=workspace&limit=1')],
|
||||
['Tags', await total('vocabulary/tags?limit=1')]];
|
||||
const mutations = [];
|
||||
page.on('request', request => {
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
|
||||
@@ -69,10 +73,11 @@ test('workspace starts with real persisted statistics without mutating data', as
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
|
||||
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
|
||||
for (const [label, count] of counts) await expect(statistic(page, label)).toHaveText(String(count));
|
||||
// #547: a card and the sidebar badge of the page it names show one number.
|
||||
for (const [route, count] of [['library', counts[0][1]], ['images', counts[1][1]], ['tags', counts[2][1]]]) {
|
||||
await expect(page.locator(`aside.sidebar nav [data-route="${route}"] .nav-count`)).toHaveText(String(count));
|
||||
}
|
||||
expect(before.stats.total_prompts).toBe(2);
|
||||
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
|
||||
expect(mutations).toEqual([]);
|
||||
await expect(page.locator('iframe')).toHaveCount(0);
|
||||
@@ -93,20 +98,106 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.reload();
|
||||
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
|
||||
// #525: Overview's cards open the native pages, and no workspace page links a legacy one.
|
||||
for (const [action, hash, title] of [
|
||||
['Open Images', '#/images', 'Images'],
|
||||
['Open Prompt library', '#/library', 'Prompt library'],
|
||||
['Open Tags & categories', '#/tags', 'Tags & categories'],
|
||||
['Open Inspect a file', '#/inspect', 'Inspect a file'],
|
||||
['Open System', '#/system/overview', 'System'],
|
||||
]) {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const main = page.locator('#workspace-main');
|
||||
const card = main.getByRole('link', { name: action, exact: true });
|
||||
await expect(card).toHaveAttribute('href', hash);
|
||||
await card.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace${hash}$`));
|
||||
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
|
||||
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
|
||||
await expect(await request.get('/prompt_manager/admin')).toBeOK();
|
||||
// The legacy metadata page stays reachable from Overview beside the native Inspect page.
|
||||
const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true });
|
||||
await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html');
|
||||
await expect(await request.get('/prompt_manager/gallery.html')).toBeOK();
|
||||
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
|
||||
await gallery.click();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
|
||||
await expect(page.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true })).toBeVisible();
|
||||
await expect(page.locator('a[href*="/prompt_manager/admin"], a[href*="/prompt_manager/gallery"]')).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main').getByText('Your existing tools remain available')).toHaveCount(0);
|
||||
// The legacy pages themselves stay at their URLs.
|
||||
for (const legacy of ['/prompt_manager/admin', '/prompt_manager/gallery', '/prompt_manager/gallery.html']) {
|
||||
await expect(await request.get(legacy)).toBeOK();
|
||||
}
|
||||
});
|
||||
|
||||
test('/prompt_manager/web lands on the workspace Library and Back does not bounce through it (#525)', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await page.evaluate(() => { location.href = '/prompt_manager/web'; });
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/overview$/);
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
|
||||
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
|
||||
await page.goto(`${workspace}#/system/overview`);
|
||||
const line = page.locator('#workspace-main .legacy-pages');
|
||||
await expect(line).toBeVisible();
|
||||
await expect(line).toContainText('Legacy pages');
|
||||
const links = line.getByRole('link');
|
||||
await expect(links).toHaveCount(4);
|
||||
for (const [name, url, ready] of [
|
||||
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
|
||||
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
|
||||
['Gallery', '/prompt_manager/gallery', 'body'],
|
||||
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
|
||||
]) {
|
||||
const opened = context.waitForEvent('page');
|
||||
await line.getByRole('link', { name, exact: true }).click();
|
||||
const tab = await opened;
|
||||
await tab.waitForLoadState('domcontentloaded');
|
||||
const target = new URL(tab.url());
|
||||
expect(target.pathname + target.hash).toBe(url);
|
||||
await expect(tab.locator(ready).first()).toBeAttached();
|
||||
await tab.close();
|
||||
}
|
||||
// Only on System's Overview section, and gone with the page.
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await expect(line).toBeHidden();
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
|
||||
});
|
||||
|
||||
const bootFailure = page => page.locator('#workspace-boot-failure');
|
||||
|
||||
test('a healthy workspace never shows the load failure message (#525)', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.waitForFunction(() => document.readyState === 'complete');
|
||||
await expect(bootFailure(page)).toBeHidden();
|
||||
});
|
||||
|
||||
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
|
||||
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', error => pageErrors.push(error.message));
|
||||
await page.route(pattern, route => route.abort());
|
||||
await page.goto(`${workspace}#/library`);
|
||||
const failure = bootFailure(page);
|
||||
await expect(failure).toBeVisible();
|
||||
await expect(failure).toHaveAttribute('role', 'alert');
|
||||
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
|
||||
await expect(dashboard).toBeVisible();
|
||||
await expect(failure.getByRole('link')).toHaveCount(4);
|
||||
await dashboard.click();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
|
||||
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
|
||||
// The broken module's own error is this test's premise, not a failure of the page.
|
||||
page.workspaceErrors.length = 0;
|
||||
});
|
||||
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
@@ -287,25 +378,65 @@ test('unavailable host palette falls back to the system theme and tracks system
|
||||
});
|
||||
|
||||
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
|
||||
const sources = ['**/prompt_manager/library?limit=1', '**/prompt_manager/images/output?mode=workspace&limit=1',
|
||||
'**/prompt_manager/vocabulary/tags?limit=1'];
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/stats', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
for (const source of sources) {
|
||||
await page.route(source, async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
}
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
|
||||
await page.unroute('**/prompt_manager/stats');
|
||||
await page.route('**/prompt_manager/stats', route => route.fulfill({
|
||||
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
|
||||
}));
|
||||
for (const source of sources) {
|
||||
await page.unroute(source);
|
||||
await page.route(source, route => route.fulfill({ json: { success: true, total: 0, items: [], images: [], tags: [] } }));
|
||||
}
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
|
||||
for (const label of ['Prompts', 'Images', 'Tags']) {
|
||||
await expect(statistic(page, label)).toHaveText('0');
|
||||
}
|
||||
await expect(page.locator('#workspace-main .empty-state')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a slow or failed Images read leaves the other Overview cards and retries only itself', async ({ page }) => {
|
||||
const images = '**/prompt_manager/images/output?mode=workspace&limit=1';
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route(images, async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
await expect(statistic(page, 'Images')).toHaveText('–');
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
await page.unroute(images);
|
||||
let libraryReads = 0;
|
||||
page.on('request', request => { if (new URL(request.url()).pathname === '/prompt_manager/library') libraryReads++; });
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
|
||||
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
|
||||
expect(libraryReads).toBe(0);
|
||||
});
|
||||
|
||||
test('opening Overview reads each count once beside the sidebar', async ({ page }) => {
|
||||
let reads = 0;
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === null) reads++;
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
|
||||
@@ -324,11 +455,9 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
let completed;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
const finished = new Promise(resolve => { completed = resolve; });
|
||||
await page.route('**/prompt_manager/stats', async route => {
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ json: {
|
||||
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
|
||||
} }).catch(() => {});
|
||||
await route.fulfill({ json: { success: true, total: 999, items: [] } }).catch(() => {});
|
||||
completed();
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
@@ -351,7 +480,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
|
||||
let reads = 0;
|
||||
page.on('request', request => {
|
||||
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
|
||||
if (new URL(request.url()).pathname === '/prompt_manager/vocabulary/tags') reads++;
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
@@ -428,6 +557,8 @@ const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Int
|
||||
'Backup & export', 'Diagnostics & logs'];
|
||||
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
|
||||
|
||||
test.describe(() => {
|
||||
test.use({ olderImagesNotice: true });
|
||||
test('System is native: the concept shell, section navigation, sections and the Save row', async ({ page }) => {
|
||||
const mutations = [];
|
||||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
|
||||
@@ -438,7 +569,7 @@ test('System is native: the concept shell, section navigation, sections and the
|
||||
// D92: no "Sample configuration" chip.
|
||||
await expect(main.locator('.heading .chip')).toHaveCount(0);
|
||||
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
|
||||
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
|
||||
const nav = main.getByRole('group', { name: 'System sections', exact: true });
|
||||
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
|
||||
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
@@ -506,6 +637,7 @@ test('System is native: the concept shell, section navigation, sections and the
|
||||
await expect(main.locator('.settings-layout')).toHaveCount(1);
|
||||
expect(mutations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Overview opens native System from its System card (Q12)', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
|
||||
@@ -19,6 +19,10 @@ after the plain ones in this order:
|
||||
recorder stores ``limited`` (more than 16 candidates, so none are stored).
|
||||
An A1111 block with two negative lines is not queued any more: it offers
|
||||
no candidate to choose (#284).
|
||||
- ``unreadable`` (#536): an item whose stored candidates are all
|
||||
``unresolved``, as items recorded before the choice rule are, written directly
|
||||
(the recorder no longer stores one). The Library holds "Golden dunes at
|
||||
dusk" with its negative, and "Quiet harbor" with two.
|
||||
- ``unavailable``: a file recorded under the ``temp`` directory while it was a
|
||||
gallery root; the root is then removed again, so no current root lists it.
|
||||
|
||||
@@ -41,6 +45,7 @@ page closed is dropped at such a write.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
@@ -58,10 +63,11 @@ if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
POSITIVES = ("Golden dunes at dusk", "Harbor lights at night")
|
||||
UNREADABLE_REASON = "Unsupported latent ancestry"
|
||||
NEGATIVE = "Soft negative, blur"
|
||||
# The prompt the conflict item's file is linked to after it was queued.
|
||||
FALLBACK = "Misty forest path"
|
||||
SPECIAL = ("conflict", "limited", "unavailable")
|
||||
SPECIAL = ("conflict", "limited", "unavailable", "unreadable")
|
||||
# The concept's first three images (concept app.js `images`) and first prompt.
|
||||
CONCEPT_IMAGES = (
|
||||
("alpine_00001.png", (1536, 1152)),
|
||||
@@ -215,6 +221,38 @@ def _unavailable(api: PromptManagerAPI, root: Path, path: Path) -> None:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots
|
||||
|
||||
|
||||
def _unreadable(api: PromptManagerAPI, path: Path) -> None:
|
||||
"""Queue ``path`` with candidates no reviewer can apply, and Library prompts."""
|
||||
facts = path.stat()
|
||||
spelled = os.path.join(os.path.realpath(path.parent), path.name)
|
||||
candidates = [
|
||||
{
|
||||
"hash": hashlib.sha256(f"unreadable-{sampler}".encode()).hexdigest(),
|
||||
"sampler_id": sampler,
|
||||
"state": "unresolved",
|
||||
"reason": UNREADABLE_REASON,
|
||||
"positive": None,
|
||||
"negative": None,
|
||||
}
|
||||
for sampler in ("11", "13")
|
||||
]
|
||||
api.db.record_review_item(
|
||||
locator=os.path.normcase(spelled),
|
||||
spelled_path=spelled,
|
||||
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
size=facts.st_size,
|
||||
mtime_ns=facts.st_mtime_ns,
|
||||
epoch=api.db.get_execution_epoch(),
|
||||
resolver_version=1,
|
||||
evidence={"reason": "", "resolver_version": 1, "candidates": candidates},
|
||||
candidate_count=len(candidates),
|
||||
limited=False,
|
||||
)
|
||||
api.db.save_prompt_pair(POSITIVES[0], NEGATIVE)
|
||||
api.db.save_prompt_pair("Quiet harbor", "grainy")
|
||||
api.db.save_prompt_pair("Quiet harbor", "washed out")
|
||||
|
||||
|
||||
def seed_review(
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
@@ -254,6 +292,9 @@ def seed_review(
|
||||
path = (root / "temp" if kind == "unavailable" else output) / relative
|
||||
if kind == "conflict":
|
||||
_linked_after_queueing(api, path)
|
||||
elif kind == "unreadable":
|
||||
_png(path, (70, 40, 120))
|
||||
_unreadable(api, path)
|
||||
elif kind == "limited":
|
||||
many = tuple(f"{POSITIVES[0]} {n}" for n in range(LIMITED_SAMPLERS))
|
||||
_png(path, (90, 90, 90), graph=_graph(positives=many))
|
||||
|
||||
@@ -210,14 +210,14 @@ test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page },
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
|
||||
test(`${label} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
|
||||
await addEditor(page, type);
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||
await expect(popup.locator('body')).toContainText('Prompt');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
|
||||
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
|
||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||
|
||||
@@ -93,7 +93,7 @@ test('local file roles send exactly across renderer transitions',async({page,con
|
||||
} finally {await restore(page);}
|
||||
}
|
||||
}
|
||||
// A connected destination input rejects the whole preview: neither role changes, nothing queues or loads.
|
||||
// A connected destination input blocks the whole send: neither role changes, nothing queues or loads.
|
||||
const link=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text');
|
||||
@@ -108,8 +108,11 @@ test('local file roles send exactly across renderer transitions',async({page,con
|
||||
const before=await nodeState(page,id);
|
||||
expect(before.widgets).toMatchObject({text:'Before positive',negative_text:'Before negative'});
|
||||
const rail=await inspect(workspace,FILES[1]);
|
||||
const sender=await preview(workspace,rail);
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
// #535: the linked node is not offered, and the status line says why.
|
||||
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await expect(sender).toContainText(/has its negative prompt linked to another node/);
|
||||
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await nodeState(page,id);
|
||||
expect(rejected.effects).toEqual({queue:0,load:0});
|
||||
|
||||
@@ -117,10 +117,9 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
try {
|
||||
await send.click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
// #535: the linked node is not offered, and the status line says why.
|
||||
await expect(sender).toContainText(/has its negative prompt linked to another node/);
|
||||
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
|
||||
|
||||
@@ -55,9 +55,10 @@ test('paired default layout uses equal editors and follows real host themes',asy
|
||||
}
|
||||
});
|
||||
|
||||
test('paired explicit Save persists an empty negative through actual API and preserves local draft',async({page,request},testInfo)=>{
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
test('paired explicit Save persists an empty negative through actual API, gives a new prompt its metadata and preserves local draft',async({page,request},testInfo)=>{
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByText('Metadata · category / tags',{exact:true}).click();await root.getByRole('combobox',{name:'Category',exact:true}).fill('Saved category');await root.getByRole('combobox',{name:'Tags',exact:true}).fill('saved-tag');await root.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();const response=await request.get(`/prompt_manager/pairs?text=${encodeURIComponent(text)}`);expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:text,negative_text:''})]));expect(await values(page,id)).toMatchObject({text,negative_text:''});
|
||||
const library=await (await request.get(`/prompt_manager/library?text=${encodeURIComponent(text)}&limit=10`)).json();expect(library.items).toEqual(expect.arrayContaining([expect.objectContaining({category:'Saved category',tags:['saved-tag']})]));
|
||||
});
|
||||
|
||||
test('paired library filters search both texts, persist on reload and keep editor metadata separate',async({page,request},testInfo)=>{
|
||||
|
||||
@@ -84,7 +84,7 @@ test('all node schemas and extension entrypoints load from the real host', async
|
||||
});
|
||||
|
||||
for (const type of ['PromptManager', 'PromptManagerText']) {
|
||||
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
|
||||
test(`${type} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => {
|
||||
await page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(type);
|
||||
@@ -95,14 +95,74 @@ for (const type of ['PromptManager', 'PromptManagerText']) {
|
||||
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||
await expect(popup.locator('body')).toContainText('Prompt');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
|
||||
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('top-bar PM button opens the workspace in either renderer', async ({ page }) => {
|
||||
const button = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
|
||||
await expect(button).toBeVisible();
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await button.click();
|
||||
const popup = await popupPromise;
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
|
||||
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
for (const [type, buttonName] of [['PromptManager', '🌐 Web UI'], ['PromptManagerFocused', 'Web UI ↗'], ['PromptManagerPair', 'Web UI ↗']]) {
|
||||
test(`${type} and the PM button open the workspace after switching renderers both ways, a workflow reload and node removal`, async ({ page }, testInfo) => {
|
||||
const id = await page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(type);
|
||||
app.graph.add(node);
|
||||
node.setSize([900, 800]);
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
}, type);
|
||||
const webUi = page.getByRole('button', { name: buttonName, exact: true });
|
||||
const pm = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first();
|
||||
const opensWorkspace = async button => {
|
||||
await expect(button).toHaveCount(1);
|
||||
await expect(button).toBeVisible();
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await button.click();
|
||||
const popup = await popupPromise;
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/);
|
||||
await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await popup.close();
|
||||
};
|
||||
await opensWorkspace(webUi);
|
||||
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.ds.scale = .8;
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await opensWorkspace(webUi);
|
||||
await opensWorkspace(pm);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
});
|
||||
await opensWorkspace(webUi);
|
||||
await opensWorkspace(pm);
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph.getNodeById(id));
|
||||
}, id);
|
||||
await expect(webUi).toHaveCount(0);
|
||||
await opensWorkspace(pm);
|
||||
});
|
||||
}
|
||||
|
||||
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
|
||||
@@ -68,8 +68,14 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);
|
||||
await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
// #535: the window is named by its tab, and one window with one node needs no choosing.
|
||||
const tabTitle=await page.title();
|
||||
await expect(hosts.locator('option').nth(1)).toHaveText(tabTitle);
|
||||
expect(tabTitle).not.toContain('PromptManager');
|
||||
await expect(hosts).not.toHaveValue('');
|
||||
await expect(sender.getByRole('combobox',{name:'Destination node',exact:true})).not.toHaveValue('');
|
||||
await expect(sender.getByRole('status')).toContainText(`in ${tabTitle}. Apply to send it, or preview the changes first.`);
|
||||
await expect(sender.getByRole('combobox',{name:'Destination node',exact:true}).locator('option').nth(1)).toHaveText(new RegExp(`^#${id} `));
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
@@ -86,5 +92,66 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
|
||||
await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')});
|
||||
// #535 D175: Apply without Preview, one click, and a double click sends once.
|
||||
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});
|
||||
const find0=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
|
||||
const positiveBox=nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true});
|
||||
await positiveBox.fill('Typed before one click');
|
||||
await find0.click();
|
||||
await expect(apply).toBeEnabled();
|
||||
await apply.dblclick();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
await expect(positiveBox).toHaveValue(positive);
|
||||
await expect(apply).toBeDisabled();
|
||||
// Edited in ComfyUI after the send: a stray second send would overwrite this.
|
||||
await positiveBox.fill('Typed after the send');
|
||||
await expect(positiveBox).toHaveValue('Typed after the send');
|
||||
// A changed workflow since discovery refuses through one-click Apply, and writes nothing.
|
||||
await find0.click();
|
||||
await expect(apply).toBeEnabled();
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
|
||||
await apply.click();
|
||||
await expect(sender.getByRole('alert')).toContainText(/workflow changed/i);
|
||||
await expect(positiveBox).toHaveValue('Typed after the send');
|
||||
// A field linked after discovery refuses the same way.
|
||||
await find0.click();
|
||||
await expect(apply).toBeEnabled();
|
||||
await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name==='negative_text');
|
||||
if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget);
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text'));
|
||||
},id);
|
||||
await apply.click();
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
await expect(positiveBox).toHaveValue('Typed after the send');
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.disconnectInput(node.inputs.findIndex(input=>input.name==='negative_text'));},id);
|
||||
// Two identical nodes are never selected for you, and read "#<id> <title>".
|
||||
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
|
||||
await find0.click();
|
||||
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
|
||||
await expect(targets.locator('option')).toHaveCount(3);
|
||||
await expect(targets).toHaveValue('');
|
||||
await expect(apply).toBeDisabled();
|
||||
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
|
||||
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
|
||||
// #535: the status line says why a window cannot receive.
|
||||
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
|
||||
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
|
||||
await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name==='text');
|
||||
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
|
||||
},id);
|
||||
await find.click();
|
||||
await expect(sender).toContainText(/has its positive prompt linked to another node/);
|
||||
await expect(preview).toBeDisabled();
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');app.graph.clear();});
|
||||
await find.click();
|
||||
await expect(sender).toContainText('has no Prompt Manager node.');
|
||||
await expect(preview).toBeDisabled();
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
|
||||
} });
|
||||
expect(saved.ok()).toBe(true);
|
||||
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
|
||||
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
|
||||
const workspace = await context.newPage();
|
||||
const errors = [];
|
||||
workspace.on('pageerror', error => errors.push(error.message));
|
||||
@@ -16,7 +16,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
await workspace.goto('/prompt_manager/workspace#/overview');
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
|
||||
.toHaveText(stats.total_prompts.toLocaleString());
|
||||
.toHaveText(library.total.toLocaleString());
|
||||
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
const imageResponse = workspace.waitForResponse(response => {
|
||||
@@ -37,11 +37,11 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
|
||||
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
|
||||
}
|
||||
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
|
||||
// D29: Images no longer links the legacy gallery; #525: nor does Overview. The page stays at its URL.
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
|
||||
await workspace.evaluate(() => { location.hash = '/overview'; });
|
||||
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
const imagesCard = workspace.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true });
|
||||
await expect(imagesCard).toHaveAttribute('href', '#/images');
|
||||
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
|
||||
await workspace.evaluate(() => { location.hash = '/images'; });
|
||||
await workspace.reload();
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"POST /prompt_manager/restore/apply",
|
||||
"POST /prompt_manager/restore/preview",
|
||||
"POST /prompt_manager/review/items/{item_id}/decision",
|
||||
"POST /prompt_manager/review/items/{item_id}/link",
|
||||
"POST /prompt_manager/save",
|
||||
"POST /prompt_manager/saved-views",
|
||||
"POST /prompt_manager/scan",
|
||||
|
||||
@@ -21,11 +21,14 @@ export function mountedDom() {
|
||||
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
|
||||
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
|
||||
focus(){this.focused=true;}
|
||||
click(){this.fire('click');}
|
||||
getClientRects(){return this.isConnected&&!this.layoutHidden?[{}]:[];}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.fire('close');}
|
||||
contains(node){return node===this||this.children.some(c=>c.contains(node));}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
|
||||
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
|
||||
querySelectorAll(selector){return walk(this,n=>n.tagName===selector);}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loaded=true;}
|
||||
play(){this.played=true;return Promise.resolve();}
|
||||
|
||||
@@ -13,7 +13,7 @@ function dom() {
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
|
||||
click(target){this.listeners.get('click')?.({target});}
|
||||
}
|
||||
const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
|
||||
const previous=global.document,toast=new Element('div');global.document={querySelectorAll:()=>[],createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
|
||||
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
|
||||
return {Element,walk,toast,restore:()=>{global.document=previous;}};
|
||||
}
|
||||
|
||||
@@ -151,3 +151,22 @@ test('callback failure preserves serialization normalized between preview and ap
|
||||
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
|
||||
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
|
||||
});
|
||||
|
||||
test('listTargets reports which roles are linked to an upstream node',async()=>{
|
||||
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
|
||||
const widgets=[{name:'text',value:''},{name:'negative_text',value:''}];
|
||||
const app={graph:{_nodes:[{id:1,type:'PromptManagerPair',widgets,inputs:[{name:'text',link:null},{name:'negative_text',link:4}]},{id:2,type:'PromptManager',widgets,inputs:[]}]}};
|
||||
const [pair,plain]=createPromptAdapter(app,{runtime:{}}).listTargets();
|
||||
assert.deepEqual(pair.linkedRoles,['negative']);assert.deepEqual(plain.linkedRoles,[]);
|
||||
});
|
||||
|
||||
test('the revision moves when the graph is edited and when a load replaces the node, even at the same version',async()=>{
|
||||
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
|
||||
const node=()=>({id:1,type:'PromptManager',widgets:[{name:'text',value:''}],inputs:[]});
|
||||
const app={graph:{_version:9,_nodes:[node()]}};
|
||||
const adapter=createPromptAdapter(app,{runtime:{}});
|
||||
const first=adapter.listTargets()[0].revision;
|
||||
assert.equal(adapter.preview('1',{positive:'x'}).preview.revision,first);
|
||||
app.graph._version=10;const edited=adapter.listTargets()[0].revision;assert.notEqual(edited,first);
|
||||
app.graph={_version:10,_nodes:[node()]};assert.notEqual(adapter.listTargets()[0].revision,edited);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installPromptBridge } from '../../web/js/prompt-bridge-host.js';
|
||||
import { createBridgeClient, createBridgeHost } from '../../web/js/shared/toolkit/prompt-bridge.js';
|
||||
|
||||
function fixture() {
|
||||
const runtime = new EventTarget();
|
||||
@@ -19,7 +20,10 @@ test('host uses the exact deployment prefix and explicit pack label', () => {
|
||||
assert.equal(f.hosts.length, 1);
|
||||
assert.equal(f.hosts[0].config.namespace, '/team/');
|
||||
assert.equal(f.hosts[0].config.origin, 'https://comfy.example');
|
||||
assert.equal(f.hosts[0].config.label, 'PromptManager — My workflow');
|
||||
assert.equal(f.hosts[0].config.label, 'My workflow');
|
||||
f.runtime.document.title = '';
|
||||
assert.equal(f.hosts[0].config.label, 'ComfyUI');
|
||||
f.runtime.document.title = 'My workflow';
|
||||
assert.equal(f.hosts[0].config.adapter, f.adapter);
|
||||
assert.equal(installPromptBridge(app, f.options), controller);
|
||||
controller.dispose(); controller.dispose();
|
||||
@@ -66,3 +70,24 @@ test('deployment namespace rejects external origins and unexpected paths', () =>
|
||||
assert.equal(f.hosts.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('each discovery reports the title the tab has now, through the real Toolkit host', async () => {
|
||||
const f = fixture();
|
||||
f.runtime.location = { origin: 'https://comfy.example', href: 'https://comfy.example/' };
|
||||
f.options.baseUrl = '/prompt_manager/';
|
||||
const listeners = new Set();
|
||||
const channelFactory = () => { const own = new Set(); return {
|
||||
addEventListener(type, listener) { if (type === 'message') { own.add(listener); listeners.add(listener); } }, removeEventListener(type, listener) { listeners.delete(listener); },
|
||||
postMessage(data) { for (const listener of [...listeners]) if (!own.has(listener)) queueMicrotask(() => listener({ data: structuredClone(data), origin: 'https://comfy.example' })); }, close() {},
|
||||
}; };
|
||||
// Not spread: a copy would freeze the title the host must keep reading.
|
||||
f.options.hostFactory = config => { config.channelFactory = channelFactory; return createBridgeHost(config); };
|
||||
f.adapter.listTargets = () => []; f.adapter.preview = () => {}; f.adapter.apply = () => {};
|
||||
f.runtime.document.title = 'ComfyUI';
|
||||
const controller = installPromptBridge({}, f.options);
|
||||
const client = createBridgeClient({ namespace: '/', origin: 'https://comfy.example', channelFactory });
|
||||
f.runtime.document.title = '*Unsaved Workflow - ComfyUI';
|
||||
try {
|
||||
assert.equal((await client.discover({ waitMs: 100 }))[0].label, '*Unsaved Workflow - ComfyUI');
|
||||
} finally { client.dispose(); controller.dispose(); }
|
||||
});
|
||||
|
||||
@@ -11,18 +11,17 @@ function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
|
||||
}
|
||||
test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
|
||||
const {createSendSession} = await source; const s=setup(createSendSession);
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.preview(); assert.equal(s.calls.length,0);
|
||||
s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
|
||||
await s.session.apply();assert.equal(s.calls.length,0);
|
||||
s.session.selectTarget('one');
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
|
||||
await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
|
||||
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
|
||||
});
|
||||
test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
|
||||
test('pair roles filter incompatible nodes and select the sole destination', async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
|
||||
await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
assert.equal(s.session.state.targetId,'pair');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
|
||||
});
|
||||
test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
|
||||
@@ -44,20 +43,127 @@ test('only matching explicit acknowledgements count as success',async()=>{
|
||||
}
|
||||
});
|
||||
test('discovery, empty results and stale preview errors remain actionable',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No ComfyUI window answered/i);s.session.dispose();
|
||||
const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
|
||||
const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
|
||||
});
|
||||
test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
|
||||
const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
|
||||
const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
});
|
||||
test('pending apply explains closing cannot undo the sent update',async()=>{
|
||||
const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
|
||||
assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
|
||||
});
|
||||
const window1=(targets,label='Flow - ComfyUI',hostId='a')=>({hostId,label,targets});
|
||||
const pm=(id,roles=['positive'],extra={})=>({id,label:`Node ${id}`,roles,...extra});
|
||||
async function discovered(createSendSession,found,roles={positive:'new'}) {
|
||||
const s=setup(createSendSession,{discover:async()=>found},roles);await s.session.discover();return s;
|
||||
}
|
||||
test('one window with one eligible node is selected so Preview is available at once',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')])]);
|
||||
assert.equal(s.session.state.hostId,'a');assert.equal(s.session.state.targetId,'1');
|
||||
assert.match(s.session.state.message,/#1 Node 1 in Flow - ComfyUI.*Apply/);
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
|
||||
});
|
||||
test('several windows are left to choose, and choosing one with a single node selects that node',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'A','a'),window1([pm('2'),pm('3')],'B','b')]);
|
||||
assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a ComfyUI window/);
|
||||
s.session.selectHost('a');assert.equal(s.session.state.targetId,'1');
|
||||
s.session.selectHost('b');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a destination node in B/);
|
||||
s.session.selectHost('');assert.match(s.session.state.message,/Choose a ComfyUI window/);
|
||||
s.session.selectHost('b');s.session.selectTarget('2');s.session.selectTarget('');assert.match(s.session.state.message,/Choose a destination node/);s.session.dispose();
|
||||
});
|
||||
test('a window that cannot receive says why, naming the window and the node',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
let s=await discovered(createSendSession,[window1([])]);
|
||||
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([]),window1([],'Other','b')]);
|
||||
assert.match(s.session.state.message,/None of the 2 ComfyUI windows has a Prompt Manager node/);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']})])]);
|
||||
assert.match(s.session.state.message,/Node 1 in Flow - ComfyUI has its positive prompt linked/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1')])],{positive:'p',negative:'n'});
|
||||
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node that accepts a positive and negative prompt.*\(\+\/-\)/);s.session.dispose();
|
||||
});
|
||||
test('a linked node is not offered beside an eligible one, and an unlinked role still is',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
let s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']}),pm('2')])]);
|
||||
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['2']);assert.equal(s.session.state.targetId,'2');s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1',['positive','negative'],{linkedRoles:['negative']})])]);
|
||||
assert.equal(s.session.state.targetId,'1');s.session.dispose();
|
||||
});
|
||||
test('an auto-selected destination says how many other nodes cannot receive, and the review names the destination',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
const s=await discovered(createSendSession,[window1([pm('2')],'A','a'),window1([pm('1',['positive'],{linkedRoles:['positive']})],'B','b')]);
|
||||
assert.equal(s.session.state.targetId,'2');assert.match(s.session.state.message,/1 other Prompt Manager node cannot receive/);
|
||||
await s.session.preview();s.session.dispose();
|
||||
const named=setup(createSendSession,{discover:async()=>[window1([pm('one')])],preview:async(_,id)=>({ticket:'t',target:{id,label:'Live title'},fields:[{role:'positive',before:'',after:'new'}]})});
|
||||
await named.session.discover();await named.session.preview();assert.match(named.session.state.message,/Sending to #one Live title in Flow - ComfyUI/);named.session.dispose();
|
||||
});
|
||||
test('window order and numbering do not depend on which tab answered first',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'T','b'),window1([pm('2')],'T','a')]);
|
||||
assert.deepEqual(s.session.state.hosts.map(host=>[host.hostId,host.label]),[['a','T'],['b','T (window 2)']]);s.session.dispose();
|
||||
});
|
||||
const applied=calls=>calls.filter(call=>call[0]==='apply').length;
|
||||
test('Apply without Preview fetches the ticket and applies in one click',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
|
||||
await s.session.discover();await s.session.apply();
|
||||
assert.equal(s.session.state.phase,'success');assert.deepEqual(s.calls.map(call=>call[0]),['preview','apply']);s.session.dispose();
|
||||
});
|
||||
test('a double click applies once, during the send and after it',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
|
||||
await s.session.discover();const first=s.session.apply();const second=s.session.apply();await Promise.all([first,second]);
|
||||
assert.equal(applied(s.calls),1);await s.session.apply();assert.equal(applied(s.calls),1);s.session.dispose();
|
||||
});
|
||||
test('Apply after a preview uses its ticket and does not preview again',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});
|
||||
await s.session.discover();await s.session.preview();await s.session.apply();
|
||||
assert.deepEqual(s.calls.map(call=>call[0]),['preview','apply']);assert.equal(s.session.state.phase,'success');s.session.dispose();
|
||||
});
|
||||
test('Apply needs a destination',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession);
|
||||
await s.session.discover();await s.session.apply();assert.equal(s.calls.length,0);s.session.dispose();
|
||||
});
|
||||
test('a refusal at the fetched preview says so plainly and writes nothing; Apply can try again',async()=>{
|
||||
const {createSendSession}=await source;let refuse=true;
|
||||
const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])],preview:async(_,id)=>{if(refuse)throw Error('positive is connected to an upstream input.');return{ticket:'t',target:{id},fields:[{role:'positive',before:'',after:'new'}]};}});
|
||||
await s.session.discover();await s.session.apply();
|
||||
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/connected/);assert.doesNotMatch(s.session.state.message,/may have applied/);
|
||||
refuse=false;await s.session.apply();assert.equal(s.session.state.phase,'success');s.session.dispose();
|
||||
});
|
||||
test('a host refusal at apply is a refusal; only a lost reply is an unknown outcome',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
const s=setup(createSendSession,{discover:async()=>[window1([pm('one')])],apply:async()=>{throw Error('Workflow or prompt changed. Preview again.');}});
|
||||
await s.session.discover();await s.session.apply();assert.match(s.session.state.message,/Workflow or prompt changed/);assert.doesNotMatch(s.session.state.message,/may have applied/);s.session.dispose();
|
||||
const lost=setup(createSendSession,{discover:async()=>[window1([pm('one')])],apply:async()=>{throw Error('Bridge request timed out');}});
|
||||
await lost.session.discover();await lost.session.apply();assert.match(lost.session.state.message,/may have applied/);
|
||||
await lost.session.apply();assert.equal(lost.calls.filter(call=>call[0]==='preview').length,1);
|
||||
lost.session.selectTarget('one');await lost.session.apply();assert.equal(lost.calls.filter(call=>call[0]==='preview').length,2);lost.session.dispose();
|
||||
});
|
||||
test('a workflow changed since discovery refuses before anything is written',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
for(const op of ['apply','preview']){
|
||||
const s=setup(createSendSession,{discover:async()=>[window1([pm('one',['positive'],{revision:'1:4'})])],preview:async(_,id)=>({ticket:'t',revision:'1:5',target:{id},fields:[{role:'positive',before:'',after:'new'}]})});
|
||||
await s.session.discover();await s.session[op]();
|
||||
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/workflow changed/i);assert.equal(applied(s.calls),0);s.session.dispose();
|
||||
}
|
||||
const stale=setup(createSendSession,{discover:async()=>[window1([pm('one')])]});await stale.session.discover();await stale.session.apply();assert.equal(stale.session.state.phase,'success');stale.session.dispose();
|
||||
});
|
||||
test('two or more eligible nodes are never auto-selected, and each option shows id and title',async()=>{
|
||||
const {createSendSession}=await source;const twin=id=>({id,label:'Prompt Manager (+/-)',roles:['positive']});
|
||||
const s=await discovered(createSendSession,[window1([twin('72'),twin('73')])]);
|
||||
assert.equal(s.session.state.targetId,'');assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.label),['#72 Prompt Manager (+/-)','#73 Prompt Manager (+/-)']);
|
||||
await s.session.apply();assert.equal(s.calls.length,0);s.session.dispose();
|
||||
const one=await discovered(createSendSession,[window1([twin('72')])]);assert.equal(one.session.state.hosts[0].targets[0].label,'#72 Prompt Manager (+/-)');assert.equal(one.session.state.targetId,'72');one.session.dispose();
|
||||
});
|
||||
test('same-named windows and nodes stay distinguishable',async()=>{
|
||||
const {createSendSession}=await source;const twin=id=>({id,label:'Same',roles:['positive']});
|
||||
const s=await discovered(createSendSession,[window1([twin('7'),twin('8')],'T','a'),window1([pm('1')],'T','b')]);
|
||||
assert.deepEqual(s.session.state.hosts.map(host=>host.label),['T','T (window 2)']);
|
||||
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.label),['#7 Same','#8 Same']);s.session.dispose();
|
||||
});
|
||||
function fakeDOM() {
|
||||
class Node extends EventTarget {
|
||||
constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
|
||||
@@ -89,7 +195,7 @@ test('native sender dialog uses prefixed namespace, full safe text, explicit cho
|
||||
assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
|
||||
const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
|
||||
const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
|
||||
assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
|
||||
assert.equal(host.value,'host');assert.equal(target.value,'');target.value='one';target.dispatchEvent(new Event('change'));
|
||||
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
|
||||
assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='<unsafe>new</unsafe>'));
|
||||
const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// #525: where the canvas buttons open PromptManager (web/js/workspace-entry.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const source = import('../../web/js/workspace-entry.js');
|
||||
const state = import('../../web/js/workspace/state.mjs');
|
||||
|
||||
test('the canvas entry route is the workspace Library, a native route', async () => {
|
||||
const { WORKSPACE_ENTRY_ROUTE } = await source;
|
||||
const { resolveRoute } = await state;
|
||||
assert.equal(WORKSPACE_ENTRY_ROUTE, '/prompt_manager/workspace#/library');
|
||||
const [pathname, hash] = WORKSPACE_ENTRY_ROUTE.split('#');
|
||||
assert.equal(pathname, '/prompt_manager/workspace');
|
||||
assert.equal(resolveRoute(`#${hash}`).id, 'library');
|
||||
});
|
||||
|
||||
test('/prompt_manager/web redirects to the same route, under any mount prefix', async () => {
|
||||
const { WORKSPACE_ENTRY_ROUTE } = await source;
|
||||
const html = fs.readFileSync(path.join(__dirname, '../../web/index.html'), 'utf8');
|
||||
const [, pattern, flags, replacement] = html.match(/location\.replace\(\s*window\.location\.pathname\.replace\(\/(.+)\/([a-z]*), '([^']+)'\)/);
|
||||
const redirect = pathname => pathname.replace(new RegExp(pattern, flags), replacement);
|
||||
assert.equal(redirect('/prompt_manager/web'), WORKSPACE_ENTRY_ROUTE);
|
||||
assert.equal(redirect('/prompt_manager/web/'), WORKSPACE_ENTRY_ROUTE);
|
||||
assert.equal(redirect('/prompt_manager/index.html'), WORKSPACE_ENTRY_ROUTE);
|
||||
assert.equal(redirect('/sub/prompt_manager/web'), `/sub${WORKSPACE_ENTRY_ROUTE}`);
|
||||
});
|
||||
|
||||
test('no canvas entry point still names a legacy page', () => {
|
||||
for (const file of ['web/comfy/prompt_manager.js', 'web/js/focused-node.js', 'web/js/classic-node.js']) {
|
||||
const code = fs.readFileSync(path.join(__dirname, '../..', file), 'utf8');
|
||||
assert.doesNotMatch(code, /\/prompt_manager\/(?:admin|gallery|web)\b/, file);
|
||||
}
|
||||
});
|
||||
@@ -79,9 +79,9 @@ test('Show in folder needs the full listing identity (D38)',async()=>{
|
||||
});
|
||||
test('Show in folder outcomes use the D38 toasts',async()=>{
|
||||
const {revealOutcome}=await load();
|
||||
assert.equal(revealOutcome(null),'Opened the folder on this computer.');
|
||||
assert.equal(revealOutcome({status:403,code:'not-local'}),'Show in folder works only in a browser on the computer running ComfyUI.');
|
||||
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on this computer.');
|
||||
assert.equal(revealOutcome(null),'Opened the folder on the computer running ComfyUI.');
|
||||
assert.equal(revealOutcome({status:403,code:'not-same-origin'}),'The folder was not opened: this page\'s address does not match the server\'s.');
|
||||
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on the computer running ComfyUI.');
|
||||
assert.equal(revealOutcome({status:429,code:'busy'}),'The folder is already opening.');
|
||||
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
|
||||
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
|
||||
|
||||
@@ -69,12 +69,16 @@ function fakeDOM() {
|
||||
createElement: tag => new Element(tag),
|
||||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||||
getElementById: id => (id === 'workspace-toast' ? toast : null),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
return { Element, body, toast, restore() { globalThis.document = previous; } };
|
||||
}
|
||||
|
||||
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
|
||||
function mountBar(dom, items, checkedKeys, options = {}) {
|
||||
// The bar publishes its height for the sticky chrome (D186); nothing in this DOM ever resizes.
|
||||
globalThis.ResizeObserver ??= class { observe() {} disconnect() {} };
|
||||
document.documentElement ??= { style: { setProperty() {}, removeProperty() {} } };
|
||||
const scope = createScope();
|
||||
const grid = new dom.Element('div');
|
||||
const focusFallback = new dom.Element('h1');
|
||||
|
||||
@@ -157,6 +157,27 @@ test('Viewer.js is built only on a loaded image and an unbuilt viewer is release
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
test('a pending Viewer whose element left the stage is still destroyed, without an error', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
|
||||
const instances = [];
|
||||
// Viewer.js's destroy() ends with `this.viewer.parentNode.removeChild(this.viewer)`, and moving on empties the stage.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = true; this.viewer = document.createElement('div'); instances.push(this); }
|
||||
destroy() { if (!this.viewer.parent) throw new TypeError('null is not an object'); this.destroyed++; } resize() {} };
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
|
||||
const [loading] = instances, removed = [];
|
||||
loading.node.removeAttribute = key => removed.push(key);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
|
||||
assert.deepEqual(removed, ['src'], 'and its image is released');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
|
||||
@@ -200,7 +221,9 @@ test('viewer renders the concept shell and toolbar order', async () => {
|
||||
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
|
||||
const [header,layout] = dialog.children;
|
||||
assert.equal(header.className,'dialog-header');
|
||||
const [heading,mode,close] = header.children, [title,subtitle] = heading.children;
|
||||
const [heading,keys,mode,close] = header.children, [title,subtitle] = heading.children;
|
||||
// #532 D170: ⌘ before the view switch opens the shortcuts.
|
||||
assert.deepEqual([keys.tagName,keys.id,keys.className,keys.attributes['aria-label'],keys.title],['button','viewer-keys','icon-btn','Keyboard shortcuts','Keyboard shortcuts']);
|
||||
// #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default).
|
||||
assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']);
|
||||
mode.fire('click');
|
||||
@@ -233,6 +256,83 @@ test('viewer renders the concept shell and toolbar order', async () => {
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
|
||||
});
|
||||
|
||||
test('⌘ opens the shortcuts on top of the viewer and closing them leaves it open (#532)', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [dialog] = f.container.children, [keys] = f.walk(dialog,n => n.id === 'viewer-keys');
|
||||
const position = () => f.walk(dialog,n => n.attributes.role === 'status')[0].textContent;
|
||||
const overlays = () => f.walk(dialog,n => n.id === 'viewer-keys-dialog');
|
||||
assert.equal(overlays().length,0,'built on demand');
|
||||
assert.match(position(),/^1 of 2/);
|
||||
f.doc.fire('keydown',{key:'ArrowRight',target:f.walk(dialog,n => n.id === 'viewer-close')[0]});
|
||||
assert.match(position(),/^2 of 2/,'the arrows work with focus on ×');
|
||||
keys.fire('click');
|
||||
const [overlay] = overlays();
|
||||
assert.equal(overlay.open,true);
|
||||
assert.equal(overlay.attributes['aria-labelledby'],'viewer-keys-title');
|
||||
assert.deepEqual(f.walk(overlay,n => n.tagName === 'kbd').map(n => n.textContent),['←','→','Space','+','-','0','R','H','F','I','?','Esc','D']);
|
||||
f.doc.fire('keydown',{key:'ArrowLeft',target:keys});
|
||||
assert.match(position(),/^2 of 2/,'the arrows wait for the overlay');
|
||||
f.walk(overlay,n => n.attributes['aria-label'] === 'Close keyboard shortcuts')[0].fire('click');
|
||||
assert.equal(overlays().length,0);
|
||||
assert.equal(dialog.open,true,'the viewer stays open');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
|
||||
});
|
||||
|
||||
test('hotkeys click the toolbar\'s own controls, so an unavailable control does nothing (#532)', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')], deletes = [];
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onDelete:(entry,button) => deletes.push([entry,button])});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [dialog] = f.container.children, by = (test) => f.walk(dialog,test)[0];
|
||||
const stage = by(n => n.id === 'viewer-stage'), mode = by(n => n.id === 'viewer-mode'), remove = by(n => n.textContent === 'Delete…');
|
||||
const press = (key,init = {}) => { let prevented = false; f.doc.fire('keydown',{key,target:stage,preventDefault(){prevented = true;},...init});return prevented; };
|
||||
// No media is ready: every transform is aria-disabled, so its key is swallowed by the same check as a click.
|
||||
for (const key of ['+','=','-','0','r','H']) assert.equal(press(key),true,key);
|
||||
// Modifiers, repeats and composition leave every key alone; Shift does not.
|
||||
for (const init of [{ctrlKey:true},{metaKey:true},{altKey:true},{repeat:true},{isComposing:true}]) assert.equal(press('f',init),false);
|
||||
assert.equal(press('+',{repeat:true}),true,'zoom may repeat');
|
||||
assert.equal(press('Unbound'),false);
|
||||
// F clicks the switch, and does nothing while the switch is hidden.
|
||||
assert.equal(mode.attributes['aria-label'],'Full screen view');
|
||||
press('F',{shiftKey:true});assert.equal(mode.attributes['aria-label'],'Centered view');
|
||||
mode.layoutHidden = true;assert.equal(press('f'),false);assert.equal(mode.attributes['aria-label'],'Centered view');mode.layoutHidden = false;
|
||||
// I needs the toggle: it is there in full screen and gone when Centered.
|
||||
const details = by(n => n.id === 'viewer-details');
|
||||
press('i');assert.equal(details.hidden,true);press('i');assert.equal(details.hidden,false);
|
||||
press('f');assert.equal(press('i'),false,'no toggle when Centered');
|
||||
// Delete only opens the page's review, for this file.
|
||||
press('Delete');assert.equal(deletes.length,1);assert.equal(deletes[0][1],remove);
|
||||
press('Delete',{repeat:true});assert.equal(deletes.length,1);
|
||||
// D goes through the link's own check while the file cannot be downloaded.
|
||||
controller.publish({loading:false,error:false,record:null,refreshRequired:true,message:'Refresh required.'});
|
||||
const download = by(n => n.attributes['aria-label'] === 'Download original');
|
||||
let blocked = false;download.fire('click',{preventDefault(){blocked = true;}});assert.equal(blocked,true);
|
||||
assert.equal(press('d'),true);
|
||||
// Space and ? on the stage; a target that keeps its keys leaves them alone.
|
||||
assert.equal(press(' '),true);
|
||||
assert.equal(press('?'),true);
|
||||
const overlay = by(n => n.id === 'viewer-keys-dialog');assert.equal(overlay.open,true);
|
||||
assert.equal(press('r'),false,'off under the overlay');
|
||||
overlay.close();
|
||||
assert.equal(press('r',{target:new f.Node('body')}),false,'a target outside the viewer that is not the page');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
|
||||
const g = mountedDom(), scope = createScope();
|
||||
const plain = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
|
||||
try {
|
||||
plain.open({items,selectedKey:'c'.repeat(64)});
|
||||
const stage = g.walk(g.container,n => n.id === 'viewer-stage')[0];
|
||||
let prevented = false;g.doc.fire('keydown',{key:'Delete',target:stage,preventDefault(){prevented = true;}});
|
||||
assert.equal(prevented,false,'no Delete… in this viewer, so no Delete key');
|
||||
g.doc.fire('keydown',{key:'?',target:stage});
|
||||
assert.ok(!g.walk(g.container,n => n.tagName === 'kbd').some(n => n.textContent === 'Delete'),'and none listed');
|
||||
} finally { plain.dispose(); scope.dispose(); g.restore(); }
|
||||
});
|
||||
|
||||
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
|
||||
|
||||
@@ -128,9 +128,7 @@ test('selecting a card loads detail and option labels, first workable preselecte
|
||||
['Sampler 9 · No positive / Soft negative', true],
|
||||
['Sampler 12 · Dunes / Soft negative', false],
|
||||
['Sampler 28 · Dunes / Harsh negative', false],
|
||||
['Leave unresolved', false],
|
||||
]);
|
||||
assert.equal(c.state.options.at(-1).value, 'unresolved');
|
||||
assert.equal(c.state.choice, HASH_A);
|
||||
assert.deepEqual(c.state.prompts, { positive: { status: 'resolved', text: 'golden dunes' }, negative: { status: 'resolved', text: 'blur' } });
|
||||
c.choose(HASH_C);
|
||||
@@ -138,16 +136,17 @@ test('selecting a card loads detail and option labels, first workable preselecte
|
||||
c.choose(HASH_B);
|
||||
assert.equal(c.state.prompts.negative.text, 'noise');
|
||||
c.choose('unresolved');
|
||||
assert.equal(c.state.prompts.positive.status, 'ambiguous');
|
||||
assert.equal(c.state.choice, HASH_B, 'Leave unresolved is its own action');
|
||||
await c.select('two');
|
||||
await settle();
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.deepEqual(c.state.options.map(option => option.label), ['Leave unresolved']);
|
||||
assert.equal(c.state.choice, 'unresolved');
|
||||
assert.deepEqual(c.state.options, []);
|
||||
assert.equal(c.state.choice, null);
|
||||
assert.equal(c.state.unreadable, null, 'a limited file keeps its own warning');
|
||||
assert.match(c.state.warning, /too many sampler paths/i);
|
||||
});
|
||||
|
||||
test('apply sends the selected candidate hash or unresolved with a fresh capture', async () => {
|
||||
test('apply sends the selected candidate hash with a fresh capture and the next item opens', async () => {
|
||||
const { c, h } = await loaded();
|
||||
c.choose(HASH_B);
|
||||
const done = await c.apply();
|
||||
@@ -157,31 +156,105 @@ test('apply sends the selected candidate hash or unresolved with a fresh capture
|
||||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.deepEqual(decision.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
|
||||
dependency_fingerprint: PRINT, choice: HASH_B });
|
||||
assert.equal(c.state.phase, 'done');
|
||||
assert.equal(c.state.total, 1);
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.toast, 'Association accepted.');
|
||||
assert.equal(c.state.decisions, 1);
|
||||
// The server releases a used capture itself.
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0);
|
||||
|
||||
const again = await loaded();
|
||||
again.c.choose('unresolved');
|
||||
assert.equal(await again.c.apply(), true);
|
||||
const [unresolved] = again.h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(unresolved.body.choice, 'unresolved');
|
||||
assert.equal(unresolved.body.capture_token, 'capture-1');
|
||||
assert.equal(again.c.state.toast, 'Left unresolved.');
|
||||
});
|
||||
|
||||
test('an unavailable file can only be left unresolved, without a capture', async () => {
|
||||
test('leave unresolved is its own action', async () => {
|
||||
const { c, h } = await loaded();
|
||||
assert.equal(await c.leaveUnresolved(), true);
|
||||
const [unresolved] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(unresolved.body.choice, 'unresolved');
|
||||
assert.equal(unresolved.body.capture_token, 'capture-1');
|
||||
assert.equal(c.state.toast, 'Left unresolved.');
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
});
|
||||
|
||||
const unreadable = () => detailOf(QUEUE[0], { candidates: [
|
||||
{ ...candidate(HASH_A, '9', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
|
||||
{ ...candidate(HASH_B, '12', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
|
||||
{ ...candidate(HASH_C, '14', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported node' },
|
||||
] });
|
||||
|
||||
test('nothing selectable: the reasons are summed, there is no choice and apply sends nothing', async () => {
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
|
||||
assert.equal(c.state.choice, null);
|
||||
assert.equal(c.state.prompts, null);
|
||||
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
|
||||
['Sampler 9 · No positive / No negative (Unsupported latent ancestry)', true],
|
||||
['Sampler 12 · No positive / No negative (Unsupported latent ancestry)', true],
|
||||
['Sampler 14 · No positive / No negative (Unsupported node)', true],
|
||||
]);
|
||||
assert.deepEqual(c.state.unreadable, { count: 3, reasons: [{ reason: 'Unsupported latent ancestry', count: 2 }, { reason: 'Unsupported node', count: 1 }] });
|
||||
assert.equal(await c.apply(), false);
|
||||
assert.equal(h.of('POST', '/').length, 0);
|
||||
});
|
||||
|
||||
const linkRoute = (body = {}) => ({ [`POST ${ITEMS}/one/link`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
|
||||
item: { ...QUEUE[0], state: 'selected', revision: 1 }, pair: { id: call.body.pair_id ?? 7, image_id: 3, status: 'added' }, ...body }) });
|
||||
|
||||
test('link posts the chosen prompt and recipe with a fresh capture, then the next item opens', async () => {
|
||||
const { c, h } = await loaded({ ...linkRoute(), [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
|
||||
const [link] = h.of('POST', `${ITEMS}/one/link`);
|
||||
assert.deepEqual(link.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
|
||||
dependency_fingerprint: PRINT, positive_id: 4, pair_id: 9 });
|
||||
assert.equal(c.state.toast, 'Linked to Harbor.');
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.decisions, 1);
|
||||
});
|
||||
|
||||
test('a refused link keeps the item, releases the capture and names the reason', async () => {
|
||||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => fail(409, 'association-conflict', 'This image is already linked') });
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: null, title: 'Harbor' }), false);
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.equal(c.state.warning, 'This image is already linked');
|
||||
assert.equal(c.state.decisions, 0);
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||||
});
|
||||
|
||||
test('an unanswered link resends the same body once; another choice takes a new capture', async () => {
|
||||
let answers = 0;
|
||||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => {
|
||||
answers += 1;
|
||||
return answers === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, total: 1 });
|
||||
} });
|
||||
assert.equal(await c.link({ itemId: 'two', positiveId: 4, pairId: 9, title: 'Harbor' }), false, 'not the open item');
|
||||
assert.equal(h.of('POST', OBSERVE).length, 0);
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), false);
|
||||
assert.match(c.state.warning, /Link image again/);
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
|
||||
const [first, second] = h.of('POST', `${ITEMS}/one/link`);
|
||||
assert.deepEqual(first.body, second.body);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
});
|
||||
|
||||
test('the last decision empties the page and a later page still comes back from the re-read', async () => {
|
||||
let queue = [QUEUE[0]];
|
||||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
|
||||
queue = [];
|
||||
assert.equal(await c.leaveUnresolved(), true);
|
||||
await settle();
|
||||
assert.equal(c.state.selectedId, null);
|
||||
assert.deepEqual(c.state.items, []);
|
||||
assert.equal(c.state.warning, '');
|
||||
});
|
||||
|
||||
test('an unavailable file can only be left unresolved, without a capture or a link', async () => {
|
||||
const gone = item('one', { target: null, unavailable: true, url: null });
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page([gone])),
|
||||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(gone)),
|
||||
});
|
||||
assert.ok(c.state.options.slice(0, -1).every(option => option.disabled));
|
||||
assert.equal(c.state.choice, 'unresolved');
|
||||
assert.ok(c.state.options.every(option => option.disabled));
|
||||
assert.equal(c.state.choice, null);
|
||||
assert.match(c.state.warning, /not under a current image source/i);
|
||||
assert.equal(await c.apply(), true);
|
||||
assert.equal(await c.link({ itemId: 'one', positiveId: 1, pairId: null, title: 'x' }), false);
|
||||
assert.equal(await c.leaveUnresolved(), true);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 0);
|
||||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(decision.body.capture_token, null);
|
||||
@@ -275,11 +348,14 @@ test('a review-conflict on a still pending item reads its detail again', async (
|
||||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).at(-1).body.expected_revision, 1);
|
||||
});
|
||||
|
||||
test('leave for later writes nothing', async () => {
|
||||
test('leave for later writes nothing, opens the next item and wraps', async () => {
|
||||
const { c, h } = await loaded();
|
||||
c.leave();
|
||||
assert.equal(c.state.phase, 'done');
|
||||
await c.leave();
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.toast, null);
|
||||
assert.equal(c.state.decisions, 0);
|
||||
await c.leave();
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.equal(h.of('POST', '/').length, 0);
|
||||
assert.equal(h.of('DELETE', '/').length, 0);
|
||||
});
|
||||
@@ -781,7 +857,9 @@ test('M5: an unanswered decision that landed finishes as recorded, not as decide
|
||||
queue = [QUEUE[1]]; // the server did record it: its change arrives
|
||||
await c.refresh();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'done');
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
assert.equal(c.state.decisions, 1);
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.equal(c.state.toast, 'Association accepted.');
|
||||
assert.doesNotMatch(c.state.warning, /decided elsewhere/);
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the server used the capture');
|
||||
|
||||