Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f87a085cc | ||
|
|
ffd4b2caa3 | ||
|
|
e39f6bfd59 | ||
|
|
45fab4b1cc | ||
|
|
10a4847a6b | ||
|
|
3476f8ede9 | ||
|
|
0a7b0238ca | ||
|
|
00bda8f442 | ||
|
|
728db36ac3 | ||
|
|
5ded3cfd39 | ||
|
|
2b8126b7be |
@@ -26,7 +26,12 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
# This job runs every page workflow twice (chromium, then touch) on one worker with V8 coverage on, so it
|
||||
# takes about twice a single-browser job. Measured 2026-10-20: whole job 18m26s-24m57s (main 5ded3cf
|
||||
# 22m15s; PR head 6359999 was cancelled at the old 25-minute limit while still passing), coverage step
|
||||
# alone 20m55s-22m17s+, against ~12-15 minutes for the firefox/webkit jobs. 40 minutes leaves headroom
|
||||
# for suite growth while still stopping a hung run; re-measure if a run passes 30 minutes.
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
|
||||
+319
-16
@@ -225,6 +225,19 @@ managed host with both renderer startup configurations.
|
||||
|
||||
The shared inline and collection inspector reads the current rooted file's supported
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
|
||||
only the concept content: cover, title, filename, the evidence badge with a category
|
||||
chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
|
||||
own tags 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
|
||||
"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.
|
||||
`tests/browser/inspector-concept.spec.mjs` compares both surfaces with the concept
|
||||
(see the D20–D39 notes under Images).
|
||||
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
|
||||
response limits, source changes, nested sending, matched dark/light and mobile
|
||||
geometry, and the loaded-collection viewer. `viewer matches the concept shell and
|
||||
@@ -365,13 +378,14 @@ leftovers such as `library.css` `.row-actions`, name the PR that retires them, b
|
||||
convention.
|
||||
|
||||
The lint also runs over every page stylesheet that loads before the overlay and is
|
||||
still kept (`images.css`, `library.css`, `inspector.css`; test `kept page stylesheets
|
||||
mark their concept-selector overrides`). After PR C, `images.css` holds only the side
|
||||
panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel
|
||||
and deletes with the file. Its three remaining concept-selector overrides
|
||||
(`.prompt-text`, `.prompt-actions`, `.ca-tools .prompt-actions button`) are marked
|
||||
`#249 D22 interim` until then, and `images.css keeps no retired Images page rules`
|
||||
keeps the replaced card, grid, filter, toolbar, preview and facts rules out.
|
||||
still kept (`library.css`; test `kept page stylesheets mark their concept-selector
|
||||
overrides`). PR D deleted `images.css` and `inspector.css` (test `the retired shell,
|
||||
vocabulary, editor, Images and inspector stylesheets stay deleted`); their few
|
||||
production-only rules with a live consumer (inspector wrapping and the inert download,
|
||||
the narrow Images container, the Library and recorded-run prompt blocks, the Archive
|
||||
negative's `section.prompt-block`, the prompt sender) moved to the overlay under
|
||||
`#249 D3`, and `the overlay keeps no retired Images page or inspector rules` keeps the
|
||||
replaced card, filter, toolbar, preview, facts and inspector rules out.
|
||||
|
||||
**Comparison method.** Production must equal the concept except for the recorded
|
||||
deviations in the #249 design (spec §8, D-numbers). It is never compared against
|
||||
@@ -440,8 +454,8 @@ own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, prod
|
||||
zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape returns focus
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 and D16 (follow-up PRs: no checkbox, no
|
||||
Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
sorted alphabetically). Removed toolbar and filter-row items flow again from the
|
||||
concept's own boxes (`reflow`), checked against the concept page with those items
|
||||
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
|
||||
@@ -500,9 +514,38 @@ Recorded deviations on this page (spec §8):
|
||||
- **D5**, **D29**: no Activity button and no prototype copy (shell).
|
||||
- **D7**: the star is a real persisted favorite with "Added to favorites." /
|
||||
"Removed from favorites." and a rollback toast on failure.
|
||||
- **D8 (follow-up)**: no select checkbox or bulk bar in PR C; a follow-up PR adds them
|
||||
with real actions and removes the interim `.image-overlays > .star-button:only-child`
|
||||
rule that keeps the star top-right.
|
||||
- **D8**: every Images card has the concept's select checkbox before the star; the
|
||||
`#bulkbar` shows "N selected" and only the real actions in the concept's order (Set
|
||||
category, Export metadata, Clear). Add tags and Add to collection wait for image tags
|
||||
and collections; "Remove links…" is dropped. The selection survives Load more and
|
||||
bounded re-mounting and is cleared whenever the collection is replaced (search,
|
||||
category, sort, Filters, source change, rescan, reload). A return to Images restores
|
||||
the saved collection unreplaced and keeps its selection; a full page reload starts
|
||||
empty. The Library-embedded gallery has no checkbox and no bar. Export metadata reads
|
||||
each checked image's inspection (at most 4 at a time) and downloads one
|
||||
`prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the
|
||||
inspector's own "Download metadata JSON" envelopes in listing order); the button is
|
||||
`aria-disabled` and ignores a second press while it runs, the selection is kept, and
|
||||
the toast names the unread images (none read: no download); leaving Images aborts the
|
||||
reads with no download and no toast. Set category opens the
|
||||
concept's `simpleInput` dialog ("Categorize selected images", `input#action-input`
|
||||
labelled Category, Cancel / Apply) and posts the selection's distinct linked positive
|
||||
ids to `/prompt_manager/bulk/category` (images have no category; the filter matches
|
||||
the linked positive's). With no linked image checked it posts nothing and opens no
|
||||
dialog; an empty value keeps the dialog open; the field stops at the server's 100
|
||||
characters; success reloads the categories and the listing together (clearing the
|
||||
selection, focus to the heading, and, as any replaced collection does, the inspector
|
||||
moves to the first listed image); failure keeps the selection. The write carries no
|
||||
page signal: leaving Images while it runs lets it finish without a toast, and the return
|
||||
loads the collection afresh instead of the saved visit. Three deviations from the
|
||||
concept's `simpleInput`: the field starts empty (the concept pre-fills its demo
|
||||
"Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the
|
||||
nothing-linked check runs on the Set category press, so no dialog opens. The route
|
||||
rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range
|
||||
(deduplicated) and a category that fails `validate_category`. `images selection bar matches
|
||||
the concept` compares the bar and the checked card with one card checked; the omitted
|
||||
actions flow again from the concept's own boxes, checked against the concept page with
|
||||
them removed at runtime.
|
||||
- **D9**: no Tag button until the image-annotations slice.
|
||||
- **D10**: the card title is the linked positive's display title, else the filename;
|
||||
the `h3` keeps the relative path in `title`.
|
||||
@@ -536,6 +579,122 @@ Recorded deviations on this page (spec §8):
|
||||
by the page, whatever order the server sends.
|
||||
- **D37**: production loads 40 images per page (the concept 12), so the continuation
|
||||
and toast use 40; after Escape production returns focus to the opening image.
|
||||
- **D44** (#249 PR E): Filters reads "Filters · N" while N of Source, Folder, Media and
|
||||
Tags are set in "Refine this view" (see the Prompt library deviations).
|
||||
- **D20**: the side panel shows only the concept content; "File & provenance",
|
||||
"Workflow & raw metadata" and the downloads are only in the viewer's details
|
||||
(`#viewer-details`: the panel content without the cover in a class-less wrapper, the
|
||||
two disclosures, then the pair send and Prompt history buttons). "File & provenance"
|
||||
keeps the honest facts (current-file note, Source, Path, Size, Dimensions, Format,
|
||||
Media type, Modified, the reported-location occurrence, the saved-association
|
||||
sentence, Download original) and the concept's `Prompt evidence: <badge>` line in the
|
||||
D21 badge's words in lower case, left out when there is no badge. Raw records keep
|
||||
their names, representations and the two downloads with their unavailable reasons;
|
||||
the viewer's pair send and history buttons may wrap at 200% zoom. Tag chips in the
|
||||
viewer are plain (the concept's go to the Library). The details' disclosure
|
||||
summaries and "Download original" keep 40px coarse targets (D34). Prompt history
|
||||
closes the viewer and, when its target is the route already shown, re-renders it
|
||||
(the concept's `go()`).
|
||||
- **D20/D21 side panel**: the sticky panel scrolls inside the window
|
||||
(`max-block-size` with `overflow: auto`, not the concept's `overflow: hidden`), so
|
||||
long real prompts keep the footer reachable; an empty badge or tag `.tags` row is
|
||||
left out (the concept always renders both); the footer, its pair send and Prompt
|
||||
history appear only with resolved prompt roles or a saved link; a video file keeps
|
||||
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
|
||||
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
|
||||
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
|
||||
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
|
||||
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
|
||||
- **D38 Show in folder** (maintainer decision 2026-10-20, beyond the mock): in the
|
||||
viewer's File & provenance the Path value stays text, followed in its `dd` by a
|
||||
"Show in folder" button (`Show <filename> in folder`), only for a listed file
|
||||
(`root_index`, `root_scope`, `path`), so never in the side panel or a recorded run.
|
||||
It POSTs that identity as JSON to `/prompt_manager/images/reveal` and toasts:
|
||||
|
||||
| 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." |
|
||||
| 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
|
||||
`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
|
||||
`_gallery_scope_error` (409 `source-changed`), and the path resolves only inside
|
||||
that root to a regular media file (403/404; a resolution `OSError` such as
|
||||
`ENAMETOOLONG` is the same JSON 404, and the client's path is never logged at error
|
||||
level). One reveal runs at a time per process with a 1 s cooldown after it
|
||||
(`RevealGate`, 429 `busy`), on its own single worker (`reveal_executor`), never
|
||||
ComfyUI's default executor. Just before launching, `utils/file_reveal.py` re-checks
|
||||
that the real path is still a regular file inside the root (a swapped parent is
|
||||
404). Windows starts no process: `ILCreateFromPathW` and
|
||||
`SHOpenFolderAndSelectItems` select the file through the shell API (Explorer's own
|
||||
command line splits on commas, so a path like `output\a,\Windows\...` must never
|
||||
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
|
||||
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`,
|
||||
the shell API and `os.startfile`; the browser server's opener is a stub
|
||||
(`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.
|
||||
- **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
|
||||
two tag sets are never merged. Set category (#259) writes the positive's category and
|
||||
the Images category and Tags filters match the positive's, so the panel now agrees
|
||||
with them whenever the recipe adds nothing. The inspection response names each
|
||||
field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or
|
||||
null); the category chip's visually hidden prefix (D21) says "Recipe category: " or
|
||||
"Positive prompt category: " from it, and tag chips are Tags filter buttons only when
|
||||
they are the positive's (#260 D2). History's media inspector uses the same rule. The
|
||||
concept shows the prompt's category and tags for every image and the concept
|
||||
fixture's recipes have neither, so the inspector comparison needs no mapping for it.
|
||||
- **D42**: the concept never shows a toast and a page alert together. Production keeps
|
||||
the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below)
|
||||
and, when an Import → Rescan toast leaves the Images discovery alert
|
||||
(`.options-status`), scrolls that alert into view only as far as needed, with
|
||||
`scroll-margin-block-end: 96px`, so it is never under the toast.
|
||||
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
|
||||
the toast's band at 1600 and 390 pixels and measures both boxes.
|
||||
|
||||
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
|
||||
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
|
||||
structure, cover, heading, filename, badges, prompt blocks (role labels, actions,
|
||||
text), tag chips, facts, footer, the viewer's disclosures and pair actions, and every
|
||||
offered control's name and minimum height. Each role's copy and send are named after
|
||||
the prompt, as the concept's are ("Copy positive prompt: <title>"). The concept loads
|
||||
all 24 fixture images first (D37). Behaviors: copy shows "Prompt copied." (or the
|
||||
same "Copy prompt" dialog in both); the pair send previews both roles in the concept
|
||||
and is applied and acknowledged by a test ComfyUI tab in production; the `cinematic`
|
||||
chip sets the Images Tags filter (D21: the concept searches the word, which also
|
||||
matches the untagged forest prompt's text); Prompt history goes to
|
||||
`#/library/<positive id>` (the concept's `#prompt/<id>`). `mapInspectorConcept`
|
||||
applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
|
||||
viewer's plain tag chips, laid out by the concept page itself with its chips made
|
||||
plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
|
||||
`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
|
||||
prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
|
||||
changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
|
||||
|
||||
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
|
||||
click it (the viewer reads "<title> · <filename>"; Escape closes it); press a card's
|
||||
@@ -545,10 +704,142 @@ saved file.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
|
||||
workspace-images.spec.mjs full-inspector.spec.mjs \
|
||||
workspace-images.spec.mjs full-inspector.spec.mjs inspector-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Prompt library cards and recorded deviations (#249 PR E)
|
||||
|
||||
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
`article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art`
|
||||
(at most two previews), `.card-content` (`.card-title` with the title `button` and the
|
||||
category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and
|
||||
`.card-footer` (the rating and last-use line, then `.row-actions` with the positive
|
||||
`button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`).
|
||||
`tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and
|
||||
the measured row heights on Chromium and touch.
|
||||
|
||||
Recorded deviations on this page:
|
||||
- **D23**: real card facts. "N runs" is the parent's `run_count`. The footer reads
|
||||
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>"
|
||||
("Used <Mon D>, <YYYY>" for a use in another calendar year, so last year's date never
|
||||
reads as this year's), counted in the viewer's local calendar days from `last_used_at` (a time ahead of the
|
||||
browser reads as today), and "Not used yet" without one. Stars are `★` × rating with
|
||||
a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words.
|
||||
The meta chip reads "− N negative variants" (singular for one). The title and Explore
|
||||
history are buttons that open `#/library/<id>`, as the concept's are; the trade-off
|
||||
against the old links is that they offer no open in a new tab, middle-click or URL on
|
||||
hover. The positive is clamped to two lines
|
||||
(`.prompt-card .prompt-text`); copy and send use the full text. A long title wraps
|
||||
inside the title row, and the category chip keeps whole words ("Uncategorized" for
|
||||
none). A parent with one preview, or none, fills the art row; no preview reads "No
|
||||
available image previews" (`.small`). Each card's actions add its title to their names
|
||||
("Copy positive prompt: <title>", as the concept's do).
|
||||
- **D40**: production-only shelf copy the concept never shows. A failed page reads
|
||||
"Prompt library could not be loaded. Retry." beside "Retry prompt library" (no Refresh,
|
||||
D15). A failed category list reads "Categories could not be loaded. Open the Prompt
|
||||
library again to retry." (Retry re-reads it only when the shelf failed too). The
|
||||
continuation keeps "Loading prompt library…", "No matching prompts. Try changing the
|
||||
filters." (the concept: "No matching prompts."), "You’re all caught up.", "The saved
|
||||
prompt is hidden by the current filters." and "Load more prompts".
|
||||
- **D41**: the search keeps the concept's shared placeholder "Search prompts, tags,
|
||||
filenames…" for parity with Images, although Library search matches positive text and
|
||||
titles and saved negative text and variant titles, not filenames or tags (Tags live in
|
||||
"Refine this view", D14).
|
||||
- **D43**: a card's tag chips come in the store's alphabetical order (`ORDER BY t.name`,
|
||||
as everywhere tags are read); the concept lists each sample's tags in authored order.
|
||||
- **D44**: the shared toolbar's Filters button reads "Filters · N" while N "Refine this
|
||||
view" filters are active, and plain "Filters" otherwise; the text is its accessible
|
||||
name, so the count is spoken. The concept has no indicator, and without one a Tags
|
||||
filter set by Tags & categories Browse was invisible on the shelf. The Library counts
|
||||
Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is
|
||||
not a Refine filter and is not counted. The caption's count is a `role="status"`
|
||||
region, written only when it changes and kept shown while the shelf reloads, so
|
||||
Load more and scrolling never re-announce it.
|
||||
|
||||
`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the
|
||||
concept-shaped fixture, which creates the concept's prompts last to first so Recently
|
||||
added lists them in the concept's order: heading, toolbar, caption, grid and the six
|
||||
cards (art, title row, prompt block, meta, tags, footer) at 1600x1100 and 390x844 in
|
||||
both themes and motion settings, and the grid and cards at 1800x1100 (3 columns).
|
||||
Behaviors, in both documents: the title and Explore history open the prompt's history
|
||||
(its `h1` is the title), the footer send carries the positive only (a bridge host page
|
||||
records the roles production's preview asks for), copy writes the full positive with
|
||||
"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards
|
||||
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D16 (no Save
|
||||
view; the toolbar flows again, checked against the concept without it), D23 (the
|
||||
title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the
|
||||
singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the
|
||||
concept's samples exceed at 390px, checked against the concept with the clamp rule
|
||||
applied), D35 (singular captions), D36 (sorted categories), D40 (the continuation after
|
||||
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`
|
||||
(`tests/browser/workspace.spec.mjs`). `library comparison detects
|
||||
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
|
||||
|
||||
Manual check (quick), after a browser refresh on Prompt library: each card shows the
|
||||
title with its category chip on one line, two lines of the positive with copy and send,
|
||||
"− N negative variants · N runs · N images" and the tags; the footer reads "★★★ · Used
|
||||
today" (or the real date) with send and "Explore history"; Filters → Tags still narrows
|
||||
the shelf, and the button then reads "Filters · 1" (D44), also after a Browse from Tags &
|
||||
categories.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
|
||||
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Prompt history page and recorded deviations (#249 PR F-a)
|
||||
|
||||
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
|
||||
variants panel and comparison (app.js `renderHistory` a591–616, the dialogs a1282–1304,
|
||||
style.css 1059–1179). The summary is `article.surface` with the category chip, the
|
||||
positive and selected negative prompt blocks and `.card-meta` "N tracked runs · N images
|
||||
· N negative variants" (tracked runs from the recorded window's `recorded_run_count`).
|
||||
The panel is `section.surface.timeline-panel` (region "Saved negative variants") with
|
||||
Compare negatives, + Add negative and Show archived, a `.timeline` of `.variant-lane`
|
||||
rows (`--lane-offset` 38 + 47i, the "−" `.variant-dot`, "<name> · N runs" and a
|
||||
`.run-dot` for a pair counted more than once) and `.timeline-lower` with the legend and
|
||||
All images for this positive. Recorded executions, frozen fields, send and archived
|
||||
lanes keep their contracts. `tests/browser/workspace-history.spec.mjs` and
|
||||
`workspace-library-editor.spec.mjs` cover them on Chromium and touch.
|
||||
|
||||
Spec decisions this page applies: **D24** (Send to ComfyUI is disabled only while the
|
||||
selected pair's roles load), **D25** (Compare negatives compares a baseline with the
|
||||
selected red dot, and lists the selected side's added comma terms) and **D26** (All
|
||||
images for this positive is a dialog with its own bounded gallery; the run dot opens
|
||||
Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the
|
||||
pair's newest linked image, reading the archived gallery for an archived pair).
|
||||
|
||||
Recorded deviations on this page:
|
||||
- **D45**: a variant dot is named "Select negative <title>" when no other loaded lane
|
||||
shares that title (compared trimmed), else "Select negative variant N"; a title that
|
||||
itself reads "variant N" always uses the lane's own number, so no two names repeat.
|
||||
The bounded "N of M saved negative variants" count stays a visually hidden status,
|
||||
and production's "Saved combinations, not an execution timeline." is removed.
|
||||
Archived lanes (#235) are concept lanes marked `.library-variant-archived` at the .6
|
||||
marker emphasis (their text stays at full contrast) with their Restore button. Under
|
||||
`pointer: coarse` the variant dot keeps a 47px target and the 12px run dot a 44px one
|
||||
(`::after` overlays). While a run dot shows, the lane text wraps within 139px before
|
||||
it. The panel heading, the legend row and All images wrap at 390px and 200% zoom.
|
||||
- **D46**: lanes list newest first (`ORDER BY pp.id DESC`), so the comparison's
|
||||
baseline is the oldest (lowest-id) active variant, not the first lane; while the
|
||||
variant page has more, the oldest is not loaded and the section reads "Load more
|
||||
negative variants to reach the baseline." instead of guessing. The two blocks are
|
||||
named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats.
|
||||
Compare negatives is disabled while the positive has no active variant. The page
|
||||
opens on the newest lane (existing behavior), while the concept opens on
|
||||
`negatives[0]`, its baseline: the first comparison reads "Baseline compared with
|
||||
<newest>" where the concept compares the baseline with itself. The history concept
|
||||
comparison (PR F-b, F5) maps this.
|
||||
|
||||
The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest`
|
||||
(see "Chronological Library history"), and each lane's "N runs" is the pair's
|
||||
`run_count` (see "Workspace data fields").
|
||||
|
||||
## Workspace data fields for the concept pages (#249)
|
||||
|
||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||
@@ -647,7 +938,9 @@ null. The edit token ignores both. `variant_total` has two meanings. On the list
|
||||
response it counts the active variants over all filtered parents (the same
|
||||
snapshot as `total`, the same on every cursor page). On the detail response it
|
||||
counts one positive's variants in the chosen archive scope. The Library caption
|
||||
reads it from the list state.
|
||||
reads it from the list state. Each detail variant (active and archived) also carries
|
||||
its pair's `run_count` (`prompt_pairs.run_count`, 0 when null), which the history lanes
|
||||
show as "N runs" (#249 F2).
|
||||
|
||||
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
|
||||
`{root_index, path}`. Both are idempotent and answer
|
||||
@@ -872,10 +1165,20 @@ The versioned public reads are:
|
||||
| Route | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
|
||||
| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` |
|
||||
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
|
||||
|
||||
The newest-pair read takes no query. It answers 404 when the positive is not an
|
||||
eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact
|
||||
raw-text scope newest first and follows only a row whose `reference_epoch` is the
|
||||
current epoch and whose saved-pair hint still resolves to that pair (the
|
||||
`get_history_run` hint rule). It inspects at most 16 candidates, so 16 newer stale hints
|
||||
report `{run: null}` (the dot then opens Repeat run). Like the other reads it runs inside
|
||||
the fenced history snapshot: a restore or epoch change is 409 `history-changed`, a
|
||||
storage failure 503 `storage-unavailable`.
|
||||
|
||||
Run and output reads accept the returned `epoch`; current-file routes require
|
||||
the returned epoch, current scope and occurrence coordinates. Run integers are
|
||||
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
|
||||
@@ -967,8 +1270,8 @@ page lifecycle recovery retain their separate behavioral tests.
|
||||
Initial production acceptance requires maintainer inspection of paired PNGs and
|
||||
geometry; never update snapshots automatically or mask content to bless a gap.
|
||||
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
|
||||
distinctions are documented in the comparison helper. The Images card select
|
||||
checkbox (#249 D8 follow-up) and tag button (#249 D9) and shared role-block
|
||||
distinctions are documented in the comparison helper. The Images card tag
|
||||
button (#249 D9) and shared role-block
|
||||
spacing/padding/glyph polish remain outside #209. The
|
||||
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
|
||||
mobile toolbar collision. These are explicit remaining visual differences, not
|
||||
|
||||
+24
-11
@@ -4,9 +4,17 @@ import os
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from ..utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _display_bytes(value: bytes | None) -> str | None:
|
||||
@@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
return saved, _library_projection({"status": "limited"})
|
||||
pair = pairs[0] if pairs else None
|
||||
title = pair["title"] if pair is not None else None
|
||||
category = pair["category"] if pair is not None else row["category"]
|
||||
tags, limited = _tags(
|
||||
conn,
|
||||
pair["id"] if pair is not None else row["prompt_id"],
|
||||
pair=pair is not None,
|
||||
recipe_tags, limited = (
|
||||
_tags(conn, pair["id"], pair=True) if pair is not None else ([], False)
|
||||
)
|
||||
positive_tags = []
|
||||
if not recipe_tags and not limited:
|
||||
# D39: only read the positive's tags when they are what the panel shows.
|
||||
positive_tags, limited = _tags(conn, row["prompt_id"], pair=False)
|
||||
metadata = library_metadata(
|
||||
row["category"],
|
||||
positive_tags,
|
||||
(pair["category"], recipe_tags) if pair is not None else None,
|
||||
)
|
||||
limited = limited or any(
|
||||
value is not None and len(value) > MAX_LIBRARY_BYTES
|
||||
for value in (title, category)
|
||||
for value in (title, metadata["category"])
|
||||
)
|
||||
if pair is not None:
|
||||
saved["pair"] = {
|
||||
@@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
"positive_id": row["prompt_id"],
|
||||
"pair_id": pair["id"] if pair is not None else None,
|
||||
"title": _display_bytes(title),
|
||||
"category": _display_bytes(category),
|
||||
"tags": tags,
|
||||
"metadata_scope": "pair" if pair is not None else "positive",
|
||||
**metadata,
|
||||
"category": _display_bytes(metadata["category"]),
|
||||
}
|
||||
)
|
||||
if limited:
|
||||
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
import sqlite3
|
||||
from typing import Iterator
|
||||
|
||||
from .execution_contexts import content_digest
|
||||
from .execution_contexts import _hints, _values, content_digest
|
||||
from .execution_history import HistoryEpochChanged
|
||||
from .execution_outputs import _capture, _REFERENCE_COLUMNS
|
||||
from .library import _ELIGIBLE
|
||||
@@ -98,6 +98,8 @@ _ORDER = (
|
||||
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
|
||||
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
|
||||
)
|
||||
# Stale-hint rows a newest-pair lookup inspects before it reports none.
|
||||
_PAIR_RUN_CANDIDATES = 16
|
||||
_MATCHING = (
|
||||
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
|
||||
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
|
||||
@@ -410,6 +412,62 @@ class HistoryReadOperations:
|
||||
result["context_summaries"] = list(ordered.values())
|
||||
return result
|
||||
|
||||
def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None:
|
||||
"""The newest matching run whose current saved-pair hint names `pair_id`.
|
||||
|
||||
None when the positive is not an eligible saved prompt or the pair is
|
||||
not one of its pairs; ``{"run": None}`` when no recorded run names it.
|
||||
Only a hint captured in this epoch and still resolving to the same pair
|
||||
counts, as in ``get_history_run``.
|
||||
"""
|
||||
conn = self._history_connection()
|
||||
saved = conn.execute(
|
||||
"SELECT CASE WHEN typeof(p.text)='text' AND "
|
||||
"length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text "
|
||||
f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
|
||||
(positive_id,),
|
||||
).fetchone()
|
||||
if saved is None or (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
|
||||
(pair_id, positive_id),
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
return None
|
||||
if saved["text"] is None:
|
||||
return {"run": None}
|
||||
epoch = self.get_execution_epoch()
|
||||
candidates = conn.execute(
|
||||
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
|
||||
"c.positive_snapshot_id,c.negative_snapshot_id "
|
||||
"FROM execution_runs r JOIN execution_run_contexts rc "
|
||||
"ON rc.run_id=r.run_id JOIN execution_contexts c "
|
||||
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
|
||||
"ON s.snapshot_id=c.positive_snapshot_id "
|
||||
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
|
||||
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
|
||||
(
|
||||
content_digest("text", (saved["text"],)),
|
||||
pair_id,
|
||||
epoch,
|
||||
_PAIR_RUN_CANDIDATES,
|
||||
),
|
||||
).fetchall()
|
||||
for row in candidates:
|
||||
positive = _values(conn, row["positive_snapshot_id"])
|
||||
negative = _values(conn, row["negative_snapshot_id"])
|
||||
if positive is None or positive["raw"] != saved["text"]:
|
||||
continue
|
||||
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
|
||||
if hints[2] == pair_id:
|
||||
return {
|
||||
"run": {
|
||||
key: row[key] for key in ("run_id", "anchor_id", "context_id")
|
||||
}
|
||||
}
|
||||
return {"run": None}
|
||||
|
||||
def get_history_run(self, run_id: str) -> dict | None:
|
||||
conn = self._history_connection()
|
||||
run = self.get_execution_run(run_id)
|
||||
|
||||
+3
-1
@@ -388,7 +388,7 @@ class LibraryOperations:
|
||||
for row in conn.execute(
|
||||
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
|
||||
"pp.category,pp.title,pp.archived,"
|
||||
"pp.negative_prompt_id IS NULL AS explicit_empty,"
|
||||
"pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count,"
|
||||
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
|
||||
"WHERE ip.pair_id=pp.id) AS image_count "
|
||||
"FROM prompt_pairs pp "
|
||||
@@ -404,6 +404,8 @@ class LibraryOperations:
|
||||
row["tags"] = []
|
||||
row["archived"] = bool(row["archived"])
|
||||
row["explicit_empty"] = bool(row["explicit_empty"])
|
||||
# The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset.
|
||||
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
||||
if by_id:
|
||||
marks = ",".join("?" for _ in by_id)
|
||||
for row in conn.execute(
|
||||
|
||||
@@ -388,6 +388,9 @@ class HistoryRoutesMixin:
|
||||
routes.get("/prompt_manager/library/{positive_id}/history")(
|
||||
self.get_library_history
|
||||
)
|
||||
routes.get(
|
||||
"/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest"
|
||||
)(self.get_newest_pair_run)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}/outputs")(
|
||||
self.get_history_outputs
|
||||
@@ -473,6 +476,40 @@ class HistoryRoutesMixin:
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
@_history_errors
|
||||
async def get_newest_pair_run(self, request: web.Request) -> web.Response:
|
||||
"""The pair's newest recorded run for the history run dot (#249 D26)."""
|
||||
_query(request)
|
||||
if request.query:
|
||||
raise ValueError("Invalid newest pair run query")
|
||||
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
|
||||
pair_id = _decimal(request.match_info["pair_id"], minimum=1)
|
||||
fence = self._history_fence()
|
||||
db = self.db
|
||||
|
||||
def project() -> bytes:
|
||||
generation = fence.begin()
|
||||
try:
|
||||
with db.recorded_history_snapshot() as epoch:
|
||||
result = db.get_newest_pair_run(positive_id, pair_id)
|
||||
finally:
|
||||
db.model.close()
|
||||
if result is None:
|
||||
fence.validate(epoch, generation)
|
||||
raise HistoryReadError(404, "not-found")
|
||||
encoded = _serialize_history(
|
||||
{
|
||||
"success": True,
|
||||
"history_version": 1,
|
||||
"history_epoch": epoch,
|
||||
**result,
|
||||
}
|
||||
)
|
||||
fence.validate(epoch, generation)
|
||||
return encoded
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
@_history_errors
|
||||
async def get_history_run(self, request: web.Request) -> web.Response:
|
||||
_query(request)
|
||||
|
||||
+191
-20
@@ -36,16 +36,38 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.inspection_response import encode_inspection_response
|
||||
from ...utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
)
|
||||
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
from ...utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
except ImportError:
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
from database.library import display_title
|
||||
from database.models import normalize_image_path
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.parallel import map_parallel
|
||||
from utils.inspection_response import encode_inspection_response
|
||||
from utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
)
|
||||
from utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
from utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
@@ -522,6 +544,51 @@ def _favorite_target(data: Mapping, roots: list[Path]):
|
||||
return (index, relative), None
|
||||
|
||||
|
||||
def _reveal_target(data: Mapping):
|
||||
"""``((index, scope, relative), None)`` for a reveal body, else an error.
|
||||
|
||||
Only the listing identity is accepted: an int root index, the root's
|
||||
64-hex scope and a bounded root-relative path, never an absolute,
|
||||
drive-qualified or ``..`` path.
|
||||
"""
|
||||
index, scope, relative = (
|
||||
data.get("root_index"),
|
||||
data.get("root_scope"),
|
||||
data.get("path"),
|
||||
)
|
||||
if type(index) is not int or index < 0:
|
||||
return None, bad_request("Invalid gallery root")
|
||||
if not isinstance(scope, str) or re.fullmatch(r"[0-9a-f]{64}", scope) is None:
|
||||
return None, bad_request("Invalid gallery root scope")
|
||||
if (
|
||||
not isinstance(relative, str)
|
||||
or not relative
|
||||
or len(relative) > MAX_FAVORITE_PATH
|
||||
or "\0" in relative
|
||||
or _has_traversal(relative)
|
||||
or re.match(r"^[a-zA-Z]:", relative)
|
||||
):
|
||||
return None, bad_request("path must be a relative media path")
|
||||
return (index, scope, relative), None
|
||||
|
||||
|
||||
def _reveal_not_found():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
)
|
||||
|
||||
|
||||
def _not_local():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "not-local",
|
||||
"error": "Show in folder works only on the computer running ComfyUI.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
# The path module whose case rules key favorites (tests swap in ntpath).
|
||||
_path_flavor = os.path
|
||||
|
||||
@@ -828,6 +895,11 @@ 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).
|
||||
@routes.post("/prompt_manager/images/reveal")
|
||||
async def reveal_image_route(request):
|
||||
return await self.reveal_image(request)
|
||||
|
||||
# POST: drops the server-side listing cache and rewalks the output roots.
|
||||
@routes.post("/prompt_manager/gallery/rescan")
|
||||
async def rescan_gallery_route(request):
|
||||
@@ -2156,6 +2228,108 @@ class ImageRoutesMixin:
|
||||
}
|
||||
)
|
||||
|
||||
# Swapped by tests and the disposable browser server; never a shell.
|
||||
_reveal_opener = staticmethod(reveal_in_file_manager)
|
||||
# One per process (a class attribute): single flight plus a cooldown.
|
||||
_reveal_gate = RevealGate()
|
||||
|
||||
@staticmethod
|
||||
def _reveal_path(relative: str, root: Path) -> Path | None:
|
||||
"""The real path of a regular media file under *root*, else None."""
|
||||
resolved = ImageRoutesMixin._resolve_client_media_path(relative, [root])
|
||||
if resolved is None or _media_access_error(resolved, [root]) is not None:
|
||||
return None
|
||||
real = Path(os.path.realpath(resolved))
|
||||
return real if real.is_file() and _is_within(real, root) else None
|
||||
|
||||
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).
|
||||
"""
|
||||
if not is_local_request(request.remote, request.headers, request.scheme):
|
||||
return _not_local()
|
||||
if request.content_type != "application/json":
|
||||
# A non-JSON POST is a CORS "simple" request with no preflight.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "unsupported-media-type",
|
||||
"error": "Send the request as application/json.",
|
||||
},
|
||||
status=415,
|
||||
)
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _reveal_target(data)
|
||||
if error is not None:
|
||||
return error
|
||||
index, scope, relative = target
|
||||
roots = list(self._get_all_output_dirs())
|
||||
error = self._gallery_scope_error(
|
||||
{"root_scope": scope, "root": str(index)}, roots
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
root = roots[index]
|
||||
if Path(relative).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _forbidden("Only media files can be shown")
|
||||
gate = self._reveal_gate
|
||||
if not gate.try_enter():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": "The folder is already opening.",
|
||||
},
|
||||
status=429,
|
||||
)
|
||||
try:
|
||||
return await self._reveal(relative, root)
|
||||
finally:
|
||||
gate.leave()
|
||||
|
||||
async def _reveal(self, relative: str, root: Path):
|
||||
"""Resolve and open, inside the reveal gate."""
|
||||
try:
|
||||
path = await self._run_in_executor(self._reveal_path, relative, root)
|
||||
except (OSError, ValueError) as exc:
|
||||
# ENAMETOOLONG and the like: the client's path is not logged.
|
||||
self.logger.debug(
|
||||
"Show in folder could not resolve: %s", type(exc).__name__
|
||||
)
|
||||
return _reveal_not_found()
|
||||
if path is None:
|
||||
return _reveal_not_found()
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
reveal_executor(), self._reveal_opener, path, root
|
||||
)
|
||||
except RevealTargetChanged:
|
||||
return _reveal_not_found()
|
||||
except OpenerUnavailable:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "unavailable",
|
||||
"error": "No file manager is available on this computer.",
|
||||
},
|
||||
status=501,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The exception text can hold the path: only its type at error
|
||||
# level, the traceback at debug.
|
||||
self.logger.error("Show in folder failed: %s", type(exc).__name__)
|
||||
self.logger.debug("Show in folder failure", exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "The folder could not be opened."},
|
||||
status=500,
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
def _favorite_listing(self, root: Path, files: list) -> dict[str, str]:
|
||||
"""``{relative key: listing POSIX spelling}`` for one root's scan.
|
||||
|
||||
@@ -2296,7 +2470,8 @@ class ImageRoutesMixin:
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
if not isinstance(saved, dict) or not saved:
|
||||
return result
|
||||
@@ -2304,23 +2479,19 @@ class ImageRoutesMixin:
|
||||
result["status"] = "ambiguous"
|
||||
return result
|
||||
pair = saved.get("pair")
|
||||
if isinstance(pair, dict):
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=pair.get("positive_prompt_id"),
|
||||
pair_id=pair.get("id"),
|
||||
category=pair.get("category"),
|
||||
tags=pair.get("tags"),
|
||||
metadata_scope="pair",
|
||||
)
|
||||
else:
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=saved.get("prompt_id"),
|
||||
category=saved.get("category"),
|
||||
tags=saved.get("tags"),
|
||||
metadata_scope="positive",
|
||||
)
|
||||
recipe = isinstance(pair, dict)
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=(
|
||||
pair.get("positive_prompt_id") if recipe else saved.get("prompt_id")
|
||||
),
|
||||
pair_id=pair.get("id") if recipe else None,
|
||||
**library_metadata(
|
||||
saved.get("category"),
|
||||
saved.get("tags"),
|
||||
(pair.get("category"), pair.get("tags")) if recipe else None,
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_image_prompt(self, request):
|
||||
|
||||
+43
-5
@@ -22,6 +22,29 @@ MAX_PAGE_OFFSET = 10_000_000
|
||||
# for one giant result set (and silently truncating large libraries).
|
||||
EXPORT_PAGE_SIZE = 1000
|
||||
|
||||
# The most prompts one bulk category request may change.
|
||||
BULK_CATEGORY_MAX_PROMPTS = 1000
|
||||
|
||||
# The largest value SQLite's INTEGER can bind; a larger id raises OverflowError.
|
||||
MAX_SQLITE_INT = 2**63 - 1
|
||||
|
||||
|
||||
def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS):
|
||||
"""A bulk request's ``prompt_ids``: distinct positive ints, first-seen order.
|
||||
|
||||
Raises ``ValueError`` unless ``value`` is a non-empty list of at most
|
||||
``cap`` distinct positive integers no larger than SQLite's INTEGER
|
||||
(booleans are not integers here).
|
||||
"""
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("prompt_ids must be a non-empty list")
|
||||
if any(type(pid) is not int or not 1 <= pid <= MAX_SQLITE_INT for pid in value):
|
||||
raise ValueError("prompt_ids must be positive integers")
|
||||
ids = list(dict.fromkeys(value))
|
||||
if len(ids) > cap:
|
||||
raise ValueError(f"At most {cap} prompts can be changed at once")
|
||||
return ids
|
||||
|
||||
|
||||
def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT):
|
||||
"""Return ``(limit, offset)`` from a query mapping, clamped to safe bounds.
|
||||
@@ -1145,14 +1168,29 @@ class PromptRoutesMixin:
|
||||
async def bulk_set_category(self, request):
|
||||
"""Bulk set category for prompts."""
|
||||
try:
|
||||
data = await request.json()
|
||||
prompt_ids = data.get("prompt_ids", [])
|
||||
category = (data.get("category") or "").strip()
|
||||
|
||||
if not prompt_ids:
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Expected a JSON object"}, status=400
|
||||
)
|
||||
if not data.get("prompt_ids"):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No prompt IDs provided"}, status=400
|
||||
)
|
||||
category = data.get("category")
|
||||
try:
|
||||
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
validate_category(category)
|
||||
except ValueError as ve:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(ve)}, status=400
|
||||
)
|
||||
category = (category or "").strip()
|
||||
|
||||
updated_count = await self._run_in_executor(
|
||||
self.db.bulk_set_category, prompt_ids, category
|
||||
|
||||
@@ -15,6 +15,62 @@ export const MOTIONS=Object.freeze(['no-preference','reduce']);
|
||||
export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt')
|
||||
.replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image');
|
||||
|
||||
/**
|
||||
* The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
|
||||
* hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
|
||||
* after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
|
||||
* from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
|
||||
*/
|
||||
export function reflow(row,items) {
|
||||
const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
|
||||
// The unrounded boxes (`exact`); rounding happens once, on the result.
|
||||
const rowWidth=row.exact?.width??row.width;
|
||||
const exact=item=>item.exact??{left:item.left,width:item.width};
|
||||
const grows=item=>parseFloat(item.flexGrow)>0;
|
||||
const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
|
||||
const lines=[];
|
||||
const order=row.items.filter(entry=>entry.shown);
|
||||
for(const item of items.filter(entry=>entry.shown)) {
|
||||
const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
|
||||
// The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
|
||||
if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
|
||||
}
|
||||
const placed=new Map();let top=0;
|
||||
const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height;
|
||||
const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
|
||||
for(const line of lines) {
|
||||
const free=freeOf(line);
|
||||
const growing=line.filter(grows);
|
||||
const height=Math.max(...line.map(item=>item.height));
|
||||
let x=0,previous=null;
|
||||
for(const item of line) {
|
||||
// A growing item keeps its concept width plus the space its line gained.
|
||||
const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
|
||||
// An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
|
||||
// moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
|
||||
const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
|
||||
const before=previous&&placed.get(previous);
|
||||
const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
|
||||
placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
|
||||
x=left+width+gap;previous=item;
|
||||
}
|
||||
top+=height+rowGap;
|
||||
}
|
||||
const moved=item=>{
|
||||
const box=placed.get(item);
|
||||
if(!box)return item;
|
||||
const grown=box.width-exact(item).width;
|
||||
return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
|
||||
input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
|
||||
};
|
||||
return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
|
||||
}
|
||||
|
||||
/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
|
||||
export function withoutItems(row,isRemoved) {
|
||||
return reflow(row,row.items.filter(item=>!isRemoved(item)));
|
||||
}
|
||||
|
||||
/** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */
|
||||
export async function seedConcept(request,data={}) {
|
||||
const response=await request.post('/__test__/concept/reset',{data});
|
||||
@@ -92,9 +148,11 @@ export async function mutate(page,{css=null,remove=[]}={}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, continuation, panel, library, history,
|
||||
* noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) measure
|
||||
* the concept's Images markup; `cards` holds the first six. Library and history stay null until PRs E and F.
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
|
||||
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
|
||||
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
|
||||
* anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar,
|
||||
* caption, grid, first six cards and whatever follows the grid; history stays null until PR F.
|
||||
*/
|
||||
export function inventoryOf(page,scope='main') {
|
||||
return page.evaluate(measureInventory,scope);
|
||||
@@ -329,13 +387,172 @@ export function measureInventory(scope) {
|
||||
box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
|
||||
}
|
||||
const continuation=body?.querySelector(':scope > .infinite-continuation');
|
||||
function bulkbarOf(bar) {
|
||||
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
|
||||
}
|
||||
// ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps
|
||||
// them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"),
|
||||
// so every Library box is found by descendant and placed relative to `main` or its own card.
|
||||
// A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point).
|
||||
const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]);
|
||||
function libraryCardOf(card,origin) {
|
||||
const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer');
|
||||
const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags');
|
||||
const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading');
|
||||
const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions');
|
||||
const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight));
|
||||
const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)});
|
||||
return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'),
|
||||
children:[...card.children].map(classes),
|
||||
art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'),
|
||||
items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art),
|
||||
...css(node,'objectFit')}))})),
|
||||
content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})),
|
||||
title:when(title,()=>({...layout(title),...edges(title),...relative(title,content),
|
||||
items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})),
|
||||
section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section),
|
||||
label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})),
|
||||
actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section),
|
||||
buttons:[...group.children].map(node=>controlOf(node,group))})),
|
||||
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})),
|
||||
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})),
|
||||
// A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it).
|
||||
tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width},
|
||||
items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card),
|
||||
// What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out).
|
||||
facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||||
}
|
||||
function libraryOf() {
|
||||
const grid=main?.querySelector('.library-grid');
|
||||
if(!grid)return null;
|
||||
const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption');
|
||||
const cards=[...grid.querySelectorAll('.prompt-card')];
|
||||
// Whatever the page shows after the grid, in document order (the concept: nothing).
|
||||
const trailing=[];
|
||||
for(let node=grid;node&&node!==main;node=node.parentElement)
|
||||
for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()});
|
||||
const style=getComputedStyle(grid);
|
||||
return {toolbar:when(toolbar,rowOf),
|
||||
caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main),
|
||||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span),
|
||||
top:relative(span,caption).top,height:relative(span,caption).height}))})),
|
||||
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
|
||||
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),
|
||||
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
|
||||
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
|
||||
bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf),
|
||||
caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
|
||||
grid:when(grid,gridOf),
|
||||
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
|
||||
continuation:when(continuation,continuationOf),
|
||||
panel:when(gallery,panelOf),
|
||||
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
library:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
|
||||
* a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
|
||||
* when absent. Both are measured by one in-page function with the concept's selectors in either document.
|
||||
*/
|
||||
export async function inspectorOf(page) {
|
||||
return {panel:await page.evaluate(measureInspector,'#image-inspector'),
|
||||
viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
|
||||
}
|
||||
|
||||
/** Runs in the page; self-contained. Every box is relative to the measured root. */
|
||||
export function measureInspector(selector) {
|
||||
const root=document.querySelector(selector);
|
||||
if(!root)return null;
|
||||
const px=value=>Math.round(value);
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||||
const rect=node=>node.getBoundingClientRect();
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
|
||||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
|
||||
const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||||
const classes=node=>[...node.classList].sort().join(' ');
|
||||
const when=(node,measure)=>node?measure(node):null;
|
||||
// Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
|
||||
const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||||
const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
|
||||
const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
|
||||
function iconOf(node) {
|
||||
const svg=node.querySelector(':scope > svg.icon');
|
||||
if(!svg)return null;
|
||||
return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
|
||||
}
|
||||
function controlOf(node) {
|
||||
const style=getComputedStyle(node);
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||||
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
|
||||
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
|
||||
}
|
||||
function chipOf(node) {
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
|
||||
minHeight:getComputedStyle(node).minHeight};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
|
||||
}
|
||||
function sectionOf(section) {
|
||||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
|
||||
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
|
||||
return {className:classes(section),...edges(section),...box(section),
|
||||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
|
||||
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
|
||||
actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
|
||||
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
|
||||
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
|
||||
}
|
||||
function factsOf(list) {
|
||||
const style=getComputedStyle(list);
|
||||
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
|
||||
items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
|
||||
return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
|
||||
}
|
||||
const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
|
||||
const panel=root.classList.contains('detail-panel');
|
||||
// The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
|
||||
// `display: contents` is transparent: its sections count as the wrapper's own children.
|
||||
const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
|
||||
const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
|
||||
const sections=parts.filter(item=>item.classList.contains('prompt-section'));
|
||||
const firstSection=parts.indexOf(sections[0]);
|
||||
const rows=parts.filter(item=>item.classList.contains('tags'));
|
||||
const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
|
||||
const filename=parts.find(item=>item.matches('p.small'));
|
||||
const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
|
||||
// What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
|
||||
const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
|
||||
const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
|
||||
return {
|
||||
className:classes(root),label:root.getAttribute('aria-label'),
|
||||
...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
|
||||
structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
|
||||
cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
|
||||
inner:when(inner,()=>({...edges(inner),...box(inner)})),
|
||||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
|
||||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
|
||||
filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
|
||||
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
|
||||
sections:sections.map(sectionOf),
|
||||
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
|
||||
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
|
||||
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
|
||||
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
|
||||
return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
|
||||
// The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
|
||||
actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
|
||||
controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||||
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5');
|
||||
expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' });
|
||||
expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 });
|
||||
expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
|
||||
expect(result.library).toMatchObject({ status: 'linked', category_scope: 'pair', tags_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
|
||||
expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
|
||||
const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
|
||||
expect(gallery.images[0].root_scope).toBe(complete.root_scope);
|
||||
@@ -89,7 +89,9 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
});
|
||||
|
||||
const inspector=page=>page.locator('.image-details');
|
||||
const inspector=page=>page.locator('.detail-panel');
|
||||
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
|
||||
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
|
||||
async function fullFixture(page,request) {
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
@@ -99,47 +101,257 @@ async function downloadText(page,action) {
|
||||
const [download]=await Promise.all([page.waitForEvent('download'),action.click()]);
|
||||
const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')};
|
||||
}
|
||||
/** The viewer's full details for one file (D19: the card image opens the viewer; D20: only it shows provenance and raw). */
|
||||
async function viewerDetails(page,name,root=0) {
|
||||
await page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
|
||||
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();return details;
|
||||
}
|
||||
/** Rewrites the next inspection reads; `null` passes them through. */
|
||||
async function patchInspection(page) {
|
||||
const state={patch:null};
|
||||
await page.route('**/images/inspect/**',async route=>{
|
||||
if (!state.patch) return route.continue();
|
||||
const response=await route.fetch();const data=await response.json();state.patch(data);await route.fulfill({response,json:data});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
/** The side panel's concept structure (app.js imageDetails a400–414): element, class and visible text, in order. */
|
||||
const panelStructure=panel=>panel.evaluate(node=>{
|
||||
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
|
||||
const inner=node.querySelector(':scope > .detail-inner');
|
||||
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
|
||||
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
|
||||
});
|
||||
|
||||
test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(panel).toHaveAttribute('id','image-inspector');await expect(panel).toHaveClass('detail-panel file-inspector');
|
||||
// Linked, workflow inferred: every concept element.
|
||||
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
|
||||
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
const badges=panel.locator('.detail-inner > .tags').first();
|
||||
await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection');
|
||||
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
|
||||
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
|
||||
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
const footer=panel.locator('.detail-footer');
|
||||
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
|
||||
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
|
||||
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
|
||||
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Execution recorded');
|
||||
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
|
||||
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
|
||||
// Unlinked: no badge, category, tags, pair or history.
|
||||
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
|
||||
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
// Ambiguous saved association: the same honest absence.
|
||||
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
|
||||
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
|
||||
const tagRow=panel.locator('.detail-inner > .tags').last();
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
|
||||
await selectFile(page,'complete.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
|
||||
await selectFile(page,'workflow-only.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
|
||||
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
|
||||
reads.patch=null;await page.unroute('**/images/inspect/**');
|
||||
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
|
||||
await selectFile(page,'malformed.png');
|
||||
await expect(panel.locator('.detail-inner > p.small[role="alert"]')).toHaveText('Could not read prompts from this file.');
|
||||
await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']);
|
||||
});
|
||||
|
||||
test('tag chip filters Images by that tag',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await expect(await request.post('/prompt_manager/prompts/tags',{data:{prompt_id:id,tags:['geometric']}})).toBeOK();
|
||||
// workflow-only.png is linked to the positive alone, so its chips are the positive's tags, which the filter matches.
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
|
||||
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
|
||||
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
|
||||
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
|
||||
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
|
||||
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
|
||||
await expect(page.locator('article[data-image-key="0:inspector/empty.png"]')).toHaveCount(0);
|
||||
// #249 D44: the chip's Tags filter shows on the toolbar's Filters button.
|
||||
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('geometric');
|
||||
});
|
||||
|
||||
test('pair send and history appear only with real roles and link',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await panel.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'explicit-empty.png');
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
await expect(panel.locator('details,summary,pre,a')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button',{name:/Download|Open full preview/})).toHaveCount(0);
|
||||
await expect(panel).not.toContainText(/File & provenance|Workflow & raw metadata|Download/);
|
||||
// The viewer keeps them until its own full details (D3).
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await details.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(details.getByRole('button',{name:'Download metadata JSON',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
/** The viewer's full details (app.js imageDetails(i, true)): its children, and the class-less wrapper's in order. */
|
||||
const viewerStructure=details=>details.evaluate(node=>{
|
||||
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
|
||||
const inner=node.querySelector(':scope > div');
|
||||
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
|
||||
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
|
||||
});
|
||||
|
||||
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
|
||||
// The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20).
|
||||
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']});
|
||||
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
|
||||
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
|
||||
const provenance=details.locator('details').first();await provenance.locator('summary').click();
|
||||
await expect(provenance.locator('p.small').first()).toHaveText('Current file at this source; historical bytes are not verified.');
|
||||
await expect(provenance.locator('dl.meta-grid dt')).toHaveText(['Source','Path','Size','Dimensions','Format','Media type','Modified']);
|
||||
// D38: the Path value stays text, with "Show in folder" right after it.
|
||||
await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png Show in folder');
|
||||
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
|
||||
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
|
||||
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
|
||||
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
|
||||
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
|
||||
const raw=details.locator('details').last();await raw.locator('summary').click();
|
||||
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
|
||||
for (const name of ['Download metadata JSON','Download workflow JSON']) await expect(raw.getByRole('button',{name,exact:true})).toBeVisible();
|
||||
// D21 wording for execution and embedded parameters; an unverified format names its extension without a dot.
|
||||
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'explicit-empty.png');await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details.getByText('Prompt evidence: execution recorded',{exact:true})).toBeVisible();
|
||||
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'ambiguous.png');await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
|
||||
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
|
||||
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
|
||||
// No badge, no line; no resolved pair or link, no buttons after the details.
|
||||
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
|
||||
expect((await viewerStructure(details)).outer).toEqual(['div']);
|
||||
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
|
||||
// The history button leaves the viewer for the prompt's history.
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
|
||||
await details.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
|
||||
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// D38 Show in folder: a listed file's Path offers it; the stubbed opener records what the route resolved.
|
||||
test('Show in folder asks the server to open the listed file and announces each outcome',async({page,request})=>{
|
||||
await fullFixture(page,request);
|
||||
await expect(inspector(page).getByRole('button',{name:/in folder$/})).toHaveCount(0);
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
const provenance=details.locator('details').first();await provenance.locator('summary').click();
|
||||
const path=provenance.locator('dl.meta-grid > div').nth(1);await expect(path.locator('dt')).toHaveText('Path');
|
||||
const show=path.locator('dd > button');
|
||||
await expect(show).toHaveText('Show in folder');await expect(show).toHaveAccessibleName('Show complete.png in folder');
|
||||
const toast=page.locator('#workspace-toast');
|
||||
const [sent]=await Promise.all([page.waitForRequest(entry=>entry.url().endsWith('/prompt_manager/images/reveal')),show.click()]);
|
||||
expect(sent.method()).toBe('POST');
|
||||
// The route takes only a same-origin JSON request, as a real browser sends it.
|
||||
const sentHeaders=await sent.allHeaders();
|
||||
expect(sentHeaders.origin).toBe(new URL(page.url()).origin);expect(sentHeaders['content-type']).toBe('application/json');
|
||||
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.');
|
||||
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.']]) {
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
|
||||
await show.click();await expect(toast).toHaveText(message);
|
||||
}
|
||||
// A reveal already in progress answers 429 busy.
|
||||
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.
|
||||
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']);
|
||||
});
|
||||
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);
|
||||
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
|
||||
await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence');
|
||||
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
|
||||
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
|
||||
expect(await panel.locator('.detail-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
|
||||
if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26);
|
||||
expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30);
|
||||
// The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock.
|
||||
const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
|
||||
}
|
||||
await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category');
|
||||
await expect(panel.locator('.file-vocabulary button')).toHaveCount(0);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file');
|
||||
await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/);
|
||||
await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible();
|
||||
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1);
|
||||
// Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines.
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');await expect(details).toContainText('Recipe category: Inspection');
|
||||
await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details).toContainText('Current file at this source; historical bytes are not verified.');await expect(details).toContainText('Saved association; not verified against current file');
|
||||
await expect(details.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
|
||||
await expect(details).not.toContainText(/Configuration evidence|Embedded API configuration|Saved workflow configuration/);
|
||||
await expect(details.getByRole('button',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
if (testInfo.project.name === 'chromium') {
|
||||
await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded();
|
||||
const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'});
|
||||
}
|
||||
await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1);
|
||||
await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible();
|
||||
if (testInfo.project.name === 'chromium') {
|
||||
await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded();
|
||||
const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'});
|
||||
await details.getByText('Prompt evidence: workflow inferred',{exact:true}).scrollIntoViewIfNeeded();
|
||||
const path=testInfo.outputPath('prompt-evidence.png');await page.screenshot({path});await testInfo.attach('prompt-evidence',{path,contentType:'image/png'});
|
||||
}
|
||||
await expect(details).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
|
||||
});
|
||||
|
||||
test('full inspector preserves long role text and exact sends',async({page,request,context})=>{
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});});
|
||||
try {
|
||||
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
||||
await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
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('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.');
|
||||
@@ -148,14 +360,14 @@ test('full inspector preserves long role text and exact sends',async({page,reque
|
||||
});
|
||||
|
||||
test('full inspector downloads actual bounded evidence',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await fullFixture(page,request);let panel=await viewerDetails(page,'complete.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png');
|
||||
const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json();
|
||||
await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope);
|
||||
const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url);
|
||||
await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
|
||||
await page.keyboard.press('Escape');panel=await viewerDetails(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await page.keyboard.press('Escape');panel=await viewerDetails(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
|
||||
let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/);
|
||||
});
|
||||
@@ -163,9 +375,9 @@ test('full inspector downloads actual bounded evidence',async({page,request})=>{
|
||||
test('full inspector labels a limited workflow record without hiding prompt facts',async({page,request})=>{
|
||||
// The server marks a workflow over its 4 MiB read budget `limited`: no raw text, a limited graph.
|
||||
await page.route('**/images/inspect/inspector/complete.png?*',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.record_formats.workflow='limited';delete data.metadata.raw.workflow;data.metadata.workflow_graph={status:'limited',value:null};await route.fulfill({response,json:data});});
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
|
||||
await fullFixture(page,request);const panel=await viewerDetails(page,'complete.png');
|
||||
await expect(panel.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeVisible();
|
||||
await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(panel.getByText('Record exceeds inspection limits; text is unavailable',{exact:true})).toBeVisible();await expect(panel.locator('pre code')).toHaveCount(1);
|
||||
const workflow=panel.getByRole('button',{name:'Download workflow JSON',exact:true});await expect(workflow).toHaveAttribute('aria-disabled','true');
|
||||
@@ -177,8 +389,8 @@ test('full inspector labels a limited workflow record without hiding prompt fact
|
||||
|
||||
test('full inspector rejects stale shared action state',async({page,request})=>{
|
||||
let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();});
|
||||
await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads;
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.detail-footer button.send')).toBeVisible();const before=reads;
|
||||
await openSelected(page);const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.meta-grid dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0);
|
||||
stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision');
|
||||
@@ -190,15 +402,32 @@ test('full inspector keeps long literal content reachable on mobile',async({page
|
||||
const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'verylongmodelpath/'.repeat(30);
|
||||
let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
|
||||
await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
|
||||
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.');
|
||||
await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal);
|
||||
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');}
|
||||
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
|
||||
await expect(panel.locator('.meta-grid dd').last()).toHaveText(literal);
|
||||
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();}
|
||||
await page.evaluate(()=>{document.documentElement.style.zoom='1';});
|
||||
const details=await viewerDetails(page,'complete.png');await details.getByText('Workflow & raw metadata',{exact:true}).click();await expect(details.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);
|
||||
const summary=details.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(details).toContainText('historical bytes are not verified');
|
||||
expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0);
|
||||
});
|
||||
|
||||
test('embedded gallery uses the same full inspector',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');await expect(inspector(page).getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();
|
||||
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
const heading=page.locator('#workspace-main h1');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
|
||||
await viewer.locator('#viewer-details').getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${id}$`));
|
||||
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
|
||||
// The side panel's Prompt history does the same.
|
||||
await selectFile(page,'complete.png');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
|
||||
await inspector(page).getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
|
||||
});
|
||||
|
||||
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
|
||||
@@ -230,12 +459,12 @@ test('full inspector labels container representations and releases action URLs',
|
||||
|
||||
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'control-heavy.png');
|
||||
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
|
||||
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
|
||||
for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation);
|
||||
for(const value of await panel.locator('.meta-grid dd').all())await expect(value).toHaveAccessibleDescription(explanation);
|
||||
await page.evaluate(async()=>{
|
||||
const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
|
||||
const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
|
||||
@@ -244,7 +473,7 @@ test('full inspector distinguishes missing, limited and legacy unknown generatio
|
||||
window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope});
|
||||
window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})});
|
||||
});
|
||||
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0);
|
||||
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.meta-grid').first().locator('dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.meta-grid dd[aria-describedby]')).toHaveCount(0);
|
||||
await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();});
|
||||
});
|
||||
|
||||
@@ -252,27 +481,31 @@ test.describe('full inspector coarse pointer',()=>{
|
||||
test.use({hasTouch:true,viewport:{width:390,height:844}});
|
||||
test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{
|
||||
await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page);
|
||||
await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.');
|
||||
const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('Full prompt context, fine geometry and light.');
|
||||
const fullText=await panel.locator('.prompt-section.positive p.prompt-text').textContent();expect(fullText.length).toBeGreaterThan(1000);
|
||||
const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
|
||||
for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);}
|
||||
for(const zoom of [1,2]) {
|
||||
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
|
||||
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) {
|
||||
for(const target of [panel.locator('.detail-footer button.send'),panel.getByRole('button',{name:'Prompt history',exact:true})]) {
|
||||
await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport();
|
||||
}
|
||||
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
|
||||
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toHaveText(fullText);
|
||||
}
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.locator('dialog#viewer-dialog');
|
||||
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) {
|
||||
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
|
||||
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
|
||||
}
|
||||
const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport();
|
||||
// #249 D34: the viewer details' disclosure summaries and "Download original" keep 40px coarse targets as well.
|
||||
const rail=viewer.locator('#viewer-details');await rail.getByText('File & provenance',{exact:true}).click();await expect(rail.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
|
||||
const details=await rail.locator(':is(summary,a)').evaluateAll(nodes=>nodes.map(node=>{const {width,height}=node.getBoundingClientRect();return {name:node.textContent,width,height};}));
|
||||
expect(details.map(target=>target.name)).toEqual(['File & provenance','Download original','Workflow & raw metadata']);
|
||||
for(const target of details){expect(target.width,target.name).toBeGreaterThanOrEqual(40);expect(target.height,target.name).toBeGreaterThanOrEqual(40);}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,7 +513,7 @@ const mediaDialog=page=>page.locator('dialog#viewer-dialog');
|
||||
const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true});
|
||||
const position=page=>mediaDialog(page).locator('.media-position');
|
||||
async function openCollection(page) {
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await openSelected(page);
|
||||
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
@@ -381,14 +614,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;});
|
||||
await modal.getByRole('button',{name:'Previous media',exact:true}).click();
|
||||
await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`);
|
||||
await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename);
|
||||
await expect(modal.locator('#viewer-details > div > p.small')).toHaveText(loaded[159].filename);
|
||||
await expect(modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
|
||||
await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
|
||||
await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`);
|
||||
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await expect(opener).toBeFocused();
|
||||
await page.getByRole('button',{name:'Filters',exact:true}).click();
|
||||
// #249 D44: Source and Folder are active.
|
||||
await page.getByRole('button',{name:'Filters · 2',exact:true}).click();
|
||||
await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
|
||||
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
|
||||
@@ -397,11 +631,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
|
||||
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
|
||||
await expect(position(page)).toContainText('of 1 loaded: complete.png');
|
||||
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
|
||||
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(position(page)).toContainText('of 2 loaded:');
|
||||
});
|
||||
|
||||
test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
|
||||
@@ -453,7 +691,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
|
||||
window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open();
|
||||
},preferences);
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
|
||||
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png');
|
||||
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
|
||||
}
|
||||
const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
|
||||
@@ -519,20 +757,22 @@ test('slideshow production query and route disposal cancel the active deadline',
|
||||
const pendingAtReset=await page.evaluate(()=>{const sort=document.getElementById('sort');sort.value='name';sort.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
|
||||
expect(pendingAtReset).toBe(0);await page.clock.runFor(1);
|
||||
await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
|
||||
await selectFile(page,'complete.png');await openSelected(page);await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
|
||||
await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{
|
||||
// A working clipboard keeps the copy action on the toast, so no "Copy prompt" fallback covers the viewer.
|
||||
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{}}}));
|
||||
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
|
||||
for(const action of [
|
||||
()=>modal.locator('.prompt-positive pre').click(),
|
||||
()=>modal.locator('.prompt-section.positive p.prompt-text').click(),
|
||||
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
|
||||
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
|
||||
()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).click(),
|
||||
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
|
||||
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
|
||||
async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
|
||||
async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
|
||||
()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(),
|
||||
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
|
||||
]) {
|
||||
@@ -576,7 +816,7 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};};
|
||||
return{frame:box(node),header:box(node.querySelector('.dialog-header')),stage:box(node.querySelector('#viewer-stage')),
|
||||
toolbar:box(node.querySelector('#viewer-toolbar')),rail:box(node.querySelector('#viewer-details')),
|
||||
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
|
||||
pair:node.querySelector('#viewer-details > button.send')?box(node.querySelector('#viewer-details > button.send')):null};
|
||||
})});
|
||||
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
|
||||
else await expect(rail).toHaveCSS('overflow-y','visible');
|
||||
@@ -586,19 +826,19 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
|
||||
const label=`${theme}-${width}`;await page.setViewportSize({width,height});
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.detail-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await capture(`inline-${label}`);
|
||||
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
|
||||
await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true);
|
||||
await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true);
|
||||
await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
|
||||
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
|
||||
await expect(rail.locator('.prompt-section.negative')).toContainText('blurred edges, watermark');await rail.locator('.meta-grid').first().scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
|
||||
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('details .meta-grid').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
|
||||
const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
const send=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await openSelected(page);
|
||||
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await selectFile(page,'control-heavy.png');await openSelected(page);
|
||||
await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
@@ -610,8 +850,8 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
test('collection viewer preserves original download and rooted retries',async({page,request})=>{
|
||||
await fullFixture(page,request);let deny=true;const originals=[];
|
||||
await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();});
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page);
|
||||
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
await openSelected(page);const modal=mediaDialog(page);
|
||||
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.');
|
||||
const download=modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
|
||||
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
@@ -632,19 +872,20 @@ test('collection viewer preserves original download and rooted retries',async({p
|
||||
await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720);
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.');
|
||||
await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
|
||||
await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
});
|
||||
|
||||
test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{
|
||||
await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page);
|
||||
const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const send=modal.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();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
|
||||
await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused();
|
||||
const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
|
||||
const text=modal.locator('.prompt-section.positive p.prompt-text');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
|
||||
await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1];
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.media-card.selected .image-open')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';});
|
||||
// D19: focus returns to the opening image; the panel follows the file the viewer moved to.
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('article[data-image-key="0:inspector/complete.png"] .image-open')).toBeFocused();await expect(inspector(page).locator('.detail-inner > p.small')).toHaveText(key);
|
||||
await openSelected(page);await page.evaluate(()=>{location.hash='/overview';});
|
||||
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -674,14 +915,14 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>{const style=getComputedStyle(node);return [style.display,style.flexDirection];})).toEqual(['flex','column']);
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible();
|
||||
await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport();
|
||||
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).focus();await expect(rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeInViewport();
|
||||
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
}
|
||||
await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';});
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport();
|
||||
const sendPair=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await sendPair.focus();await expect(sendPair).toBeInViewport();
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport();
|
||||
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
|
||||
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('#viewer-details').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
|
||||
expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1);
|
||||
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
|
||||
@@ -765,7 +1006,7 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true);
|
||||
await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
|
||||
const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry());
|
||||
await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
await expect(modal.locator('#viewer-details > div > p.small')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed);
|
||||
await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
|
||||
@@ -10,7 +10,7 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m
|
||||
export const canonicalDifferences = {
|
||||
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
|
||||
semantics: [
|
||||
'Saved combinations are distinct from the additional chronological executions',
|
||||
'Negative variants are saved combinations; Recorded runs list the chronological executions',
|
||||
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
|
||||
'Current-file candidates disclose unknown identity and context membership',
|
||||
],
|
||||
@@ -35,13 +35,13 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
|
||||
|
||||
export async function historyGeometry(page, production) {
|
||||
const selectors = production ? {
|
||||
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
|
||||
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
|
||||
positiveLabel: '.library-positive-lane span',
|
||||
role: '[aria-label="Saved prompt texts"] .prompt-block',
|
||||
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
|
||||
heading: '.library-variants h2', intro: '.library-variants > p.muted',
|
||||
footer: '.library-variant-footer', all: '.library-variants button:last-child',
|
||||
surface: '.library-history article.surface', timeline: '.library-history .timeline', positive: '.library-history .positive-lane',
|
||||
branch: '.library-history .variant-lane', label: '.library-history .variant-lane > span', dot: '.library-history .variant-dot[aria-pressed="true"]',
|
||||
positiveLabel: '.library-history .positive-lane span',
|
||||
role: '[aria-label="Saved prompt texts"] .prompt-section',
|
||||
prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery',
|
||||
heading: '.library-history .timeline-panel h2', intro: '.library-history .timeline-panel .panel-heading .small',
|
||||
footer: '.library-history .timeline-lower', all: '.library-history .timeline-lower button',
|
||||
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
|
||||
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
|
||||
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
|
||||
@@ -106,7 +106,7 @@ export async function exerciseCurrentPairSend(page, roles) {
|
||||
} });
|
||||
});
|
||||
const viewer = page.locator('dialog#viewer-dialog');
|
||||
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
|
||||
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' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||
|
||||
// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
|
||||
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
|
||||
@@ -30,78 +30,32 @@ function captionDay(seconds,now=new Date()) {
|
||||
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
|
||||
* hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
|
||||
* after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
|
||||
* from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
|
||||
*/
|
||||
export function reflow(row,items) {
|
||||
const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
|
||||
// The unrounded boxes (`exact`); rounding happens once, on the result.
|
||||
const rowWidth=row.exact?.width??row.width;
|
||||
const exact=item=>item.exact??{left:item.left,width:item.width};
|
||||
const grows=item=>parseFloat(item.flexGrow)>0;
|
||||
const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
|
||||
const lines=[];
|
||||
const order=row.items.filter(entry=>entry.shown);
|
||||
for(const item of items.filter(entry=>entry.shown)) {
|
||||
const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
|
||||
// The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
|
||||
if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
|
||||
}
|
||||
const placed=new Map();let top=0;
|
||||
const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height;
|
||||
const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
|
||||
for(const line of lines) {
|
||||
const free=freeOf(line);
|
||||
const growing=line.filter(grows);
|
||||
const height=Math.max(...line.map(item=>item.height));
|
||||
let x=0,previous=null;
|
||||
for(const item of line) {
|
||||
// A growing item keeps its concept width plus the space its line gained.
|
||||
const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
|
||||
// An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
|
||||
// moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
|
||||
const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
|
||||
const before=previous&&placed.get(previous);
|
||||
const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
|
||||
placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
|
||||
x=left+width+gap;previous=item;
|
||||
}
|
||||
top+=height+rowGap;
|
||||
}
|
||||
const moved=item=>{
|
||||
const box=placed.get(item);
|
||||
if(!box)return item;
|
||||
const grown=box.width-exact(item).width;
|
||||
return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
|
||||
input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
|
||||
};
|
||||
return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
|
||||
}
|
||||
|
||||
/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
|
||||
function withoutItems(row,isRemoved) {
|
||||
return reflow(row,row.items.filter(item=>!isRemoved(item)));
|
||||
}
|
||||
/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */
|
||||
const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
|
||||
/**
|
||||
* D8 (follow-up PR): the overlay row without its checkbox label. The concept stretches the star to the label's line box
|
||||
* (`align-items: normal`); alone, the row and the star are the star's own height: its min-height, or its line plus
|
||||
* padding and border when taller. Its place stays the concept's top-right (`rightInset`).
|
||||
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
|
||||
* measured from the border box as the inventory records them. The concept's border is one width on every side
|
||||
* (`border: 1px solid`), so the right border stands for the left one.
|
||||
*/
|
||||
function withoutCheckbox(overlays) {
|
||||
// A list row hides its overlays (concept `display: none`).
|
||||
if(!overlays.shown)return overlays;
|
||||
const items=overlays.items.filter(item=>item.tag!=='label');
|
||||
const own=item=>Math.max(parseFloat(item.minHeight)||0,parseFloat(item.lineHeight)+['paddingTop','paddingBottom','borderTopWidth','borderBottomWidth']
|
||||
.reduce((sum,key)=>sum+parseFloat(item[key]),0));
|
||||
const height=Math.max(...items.filter(item=>item.shown).map(own));
|
||||
// D8 (interim): alone, the star is pushed to the right by `margin-left: auto`, whose used value is the overlay's
|
||||
// free width; the checkbox PR removes that rule and this line.
|
||||
const pushed=item=>items.length===1?{marginLeft:`${overlays.width-item.width}px`}:{};
|
||||
return {...overlays,height,items:items.map(item=>item.shown?{...item,height,...pushed(item)}:item)};
|
||||
function withoutPaddedItems(row,isRemoved) {
|
||||
const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'],
|
||||
['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0));
|
||||
const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y,
|
||||
exact:item.exact&&{...item.exact,left:item.exact.left+x}};
|
||||
const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))};
|
||||
const flowed=withoutItems(inner,isRemoved);
|
||||
return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))};
|
||||
}
|
||||
|
||||
/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */
|
||||
function mapBulkbar(bulkbar,rise) {
|
||||
if(!bulkbar)return {bulkbar,shrink:0};
|
||||
if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0};
|
||||
const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text));
|
||||
const shrink=bulkbar.bar.height-bar.height;
|
||||
return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,8 +119,6 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
return {...card,
|
||||
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
|
||||
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
|
||||
// D8 (follow-up PR): no selection checkbox yet; the star keeps the concept's top-right place.
|
||||
overlays:withoutCheckbox(card.overlays),
|
||||
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
|
||||
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
|
||||
h3:{...card.info.h3,title:paths.get(filename)??null},
|
||||
@@ -178,7 +130,8 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1
|
||||
?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})};
|
||||
const continuation=mapContinuation(reference.continuation);
|
||||
return {...reference,toolbar,filterRow,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise}};
|
||||
const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise);
|
||||
return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}};
|
||||
}
|
||||
|
||||
/** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */
|
||||
@@ -204,7 +157,7 @@ function mapImportConcept(reference) {
|
||||
?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','toolbar','filterRow','caption','grid','cards','continuation','panel','noPageScroll'];
|
||||
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
|
||||
const pick=inventory=>stripExact(inventory);
|
||||
|
||||
// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
|
||||
@@ -401,7 +354,12 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
|
||||
await openProductionLoaded(page,theme,motion,total);
|
||||
const before=await page.locator(CARDS).count();
|
||||
const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
|
||||
await page.getByRole('button',{name:'Load more images',exact:true}).click();
|
||||
// A dispatched press, as on the concept side: a pointer click scrolls the row into view first, and the page's
|
||||
// own observer (rootMargin 250px) can append the final batch and hide the button before the click lands
|
||||
// (WebKit, run 39512528039). The button must still be the enabled one the user would press.
|
||||
const more=page.getByRole('button',{name:'Load more images',exact:true});
|
||||
await expect(more).toBeVisible();await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
await more.dispatchEvent('click');
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
|
||||
result[motion].loadingDiff=compareLoadingRow(result[motion].conceptLoad.loadingRow,loadingRow,loadingCount);
|
||||
@@ -471,6 +429,43 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
|
||||
}
|
||||
});
|
||||
|
||||
// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the
|
||||
// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the
|
||||
// oracle for the re-laid-out bar.
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
const check=async()=>{
|
||||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
|
||||
await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page);
|
||||
};
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const empty=await inventoryOf(page,'#main');
|
||||
await check();
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const removed=(await inventoryOf(page,'#main')).bulkbar;
|
||||
await restore();
|
||||
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const emptyActual=await inventoryOf(page,'#workspace-main');
|
||||
await check();
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`);
|
||||
// The concept itself: an empty bar, then "1 selected" and its six actions.
|
||||
expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null});
|
||||
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
|
||||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(reference,actual,manifest,['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(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
|
||||
// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
|
||||
test('images comparison detects mutations',async({page},testInfo)=>{
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
|
||||
|
||||
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
|
||||
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
|
||||
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it
|
||||
// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category
|
||||
// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are
|
||||
// artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const TITLE='Alpine observatory';
|
||||
const FIXTURE_IMAGES=24;
|
||||
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
|
||||
const PANEL_INSET=40;
|
||||
// D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the
|
||||
// concept's coarse rule covers buttons only). The recorded rule, applied to the concept under the same emulation so the
|
||||
// browser lays out what follows the taller summaries; a fine pointer never matches it.
|
||||
const D34_COARSE_TARGETS=`@media (pointer: coarse) {
|
||||
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; }
|
||||
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; }
|
||||
}`;
|
||||
// What an open "File & provenance" offers beyond the concept's text-only disclosure: D38's Show in folder right after
|
||||
// the Path value, then D20's Download original.
|
||||
const FILE_CONTROLS=[{tag:'button',text:'Show in folder',name:'Show alpine_00001.png in folder'},{tag:'a',text:'Download original',name:'Download original'}];
|
||||
|
||||
/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */
|
||||
function withFocusablePrompts(inventory) {
|
||||
const controls=[...inventory.controls];
|
||||
for(const section of inventory.sections) {
|
||||
const send=section.actions.buttons.at(-1);
|
||||
const index=controls.findIndex(control=>control.name===send.name);
|
||||
controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'});
|
||||
}
|
||||
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
|
||||
}
|
||||
|
||||
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
|
||||
function withCategoryScope(inventory) {
|
||||
const [badge,category,...rest]=inventory.badges.chips;
|
||||
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
|
||||
* production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
|
||||
* what follows them, as the Images spec's removed toolbar items do.
|
||||
*/
|
||||
export function mapInspectorConcept(reference,{viewport}) {
|
||||
const {panel,viewerPlain,fileControls}=reference;
|
||||
return {
|
||||
panel:withCategoryScope(withFocusablePrompts({...panel,
|
||||
// D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class.
|
||||
className:`${panel.className} file-inspector`,
|
||||
// D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer
|
||||
// reachable; at 700px and below, where the concept unsticks it, it grows instead.
|
||||
overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})),
|
||||
// D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library).
|
||||
viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})),
|
||||
// D38 and D20: the open "File & provenance" offers Show in folder and Download original.
|
||||
fileControls:[...fileControls,...FILE_CONTROLS],
|
||||
};
|
||||
}
|
||||
|
||||
function compare(reference,actual,viewport) {
|
||||
return diffInventory(mapInspectorConcept(reference,{viewport}),actual);
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertInspectorMatch(reference,actual,viewport) {
|
||||
const diff=compare(reference,actual,viewport);
|
||||
if(diff.length)throw new Error(`the inspector differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const panelOf=page=>page.locator('#image-inspector');
|
||||
|
||||
/**
|
||||
* The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production
|
||||
* has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison.
|
||||
*/
|
||||
async function openConceptPanel(page,theme) {
|
||||
await openConcept(page,'images',theme);await page.mouse.move(0,0);
|
||||
if(await page.locator(CARDS).count()<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
|
||||
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
|
||||
await settle(page);
|
||||
}
|
||||
async function openProductionPanel(page,theme) {
|
||||
await openProduction(page,'images',theme);await page.mouse.move(0,0);
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
|
||||
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
|
||||
async function plainViewerChips(page) {
|
||||
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
||||
await page.evaluate(()=>{
|
||||
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
|
||||
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
|
||||
button.replaceWith(span);return [span,button];
|
||||
});
|
||||
});
|
||||
await settle(page);
|
||||
return async()=>{
|
||||
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
|
||||
await restoreTargets();
|
||||
};
|
||||
}
|
||||
|
||||
/** The controls an open "File & provenance" offers between its summary and the next disclosure's (m5). */
|
||||
async function fileControlsOf(page) {
|
||||
const disclosure=page.locator('#viewer-details details').first();
|
||||
await disclosure.evaluate(node=>{node.open=true;});await settle(page);
|
||||
const {controls}=(await inspectorOf(page)).viewerDetails;
|
||||
await disclosure.evaluate(node=>{node.open=false;});await settle(page);
|
||||
const names=controls.map(control=>control.name);
|
||||
return controls.slice(names.indexOf('File & provenance')+1,names.indexOf('Workflow & raw metadata')).map(({tag,text,name})=>({tag,text,name}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
|
||||
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
|
||||
*/
|
||||
async function viewerOf(page,concept,mutation=null) {
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
|
||||
await settle(page);
|
||||
const restoreMutation=mutation?await mutate(page,mutation):null;
|
||||
if(mutation)await settle(page);
|
||||
const {viewerDetails}=await inspectorOf(page);
|
||||
const fileControls=await fileControlsOf(page);
|
||||
let viewerPlain=null;
|
||||
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
|
||||
await restoreMutation?.();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
|
||||
// The concept re-renders the Images page on the viewer's `close` event (app.js updateImages): let it land first.
|
||||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
|
||||
return {viewerDetails,viewerPlain,fileControls};
|
||||
}
|
||||
async function inventory(page,concept=false) {
|
||||
const {panel}=await inspectorOf(page);
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
|
||||
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
|
||||
}
|
||||
|
||||
/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */
|
||||
async function fakeHost(context) {
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
|
||||
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after}))},snapshot:roles}),
|
||||
apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};},
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */
|
||||
async function copyOf(page) {
|
||||
// An earlier toast (the concept's Load more) is not the copy's outcome.
|
||||
await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page);
|
||||
await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';}));
|
||||
await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true});
|
||||
await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe('');
|
||||
const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0};
|
||||
if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The concept's send: its dialog previews both roles (app.js openSend). */
|
||||
async function conceptSendOf(page) {
|
||||
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent]));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return previews;
|
||||
}
|
||||
|
||||
/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */
|
||||
async function productionSendOf(page,context) {
|
||||
const host=await fakeHost(context);
|
||||
try {
|
||||
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
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('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.');
|
||||
const applied=await host.evaluate(()=>window.applied);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
|
||||
return applied;
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
}
|
||||
|
||||
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
|
||||
async function tagOf(page) {
|
||||
const before=await page.locator('#result-count').textContent();
|
||||
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
|
||||
await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before);
|
||||
await settle(page);
|
||||
return {count:await page.locator('#result-count').textContent(),
|
||||
cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
|
||||
}
|
||||
|
||||
const historyOf=async page=>{
|
||||
await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/);
|
||||
return page.evaluate(()=>location.hash);
|
||||
};
|
||||
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
|
||||
test.setTimeout(180_000);
|
||||
// The concept's copy and production's both use the async clipboard; Chromium asks for the permission.
|
||||
if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']);
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
const viewport={width,height};
|
||||
await openConceptPanel(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`);
|
||||
const reference=await inventory(page,true);
|
||||
const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)};
|
||||
await openConceptPanel(page,theme);concept.history=await historyOf(page);
|
||||
|
||||
await openProductionPanel(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`);
|
||||
const actual=await inventory(page);
|
||||
const production={copy:await copyOf(page),send:await productionSendOf(page,context)};
|
||||
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic');
|
||||
production.tag=await tagOf(page);await listing;
|
||||
// D44: the applied tag is one active refinement, so the toolbar's Filters button counts it.
|
||||
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue();
|
||||
await page.keyboard.press('Escape');
|
||||
await openProductionPanel(page,theme);production.history=await historyOf(page);
|
||||
|
||||
const diff=compare(reference,actual,viewport);
|
||||
const path=testInfo.outputPath('inspector-geometry.json');
|
||||
await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2));
|
||||
await testInfo.attach('inspector-geometry',{path,contentType:'application/json'});
|
||||
|
||||
// The concept itself is what this comparison assumes (spec §3.3, s523–615).
|
||||
const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches);
|
||||
expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28);
|
||||
expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static');
|
||||
expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200);
|
||||
expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px');
|
||||
expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px');
|
||||
for(const section of panel.sections) {
|
||||
expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'});
|
||||
expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'});
|
||||
for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'});
|
||||
}
|
||||
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
|
||||
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
|
||||
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
|
||||
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
|
||||
|
||||
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
|
||||
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
|
||||
expect.soft(production.copy,'copy').toEqual(concept.copy);
|
||||
if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false});
|
||||
// Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them.
|
||||
const [positive,negative]=concept.send;
|
||||
expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']);
|
||||
expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]);
|
||||
expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution');
|
||||
// Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images.
|
||||
expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic');
|
||||
// The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic
|
||||
// (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order.
|
||||
const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_'));
|
||||
expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts');
|
||||
expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged});
|
||||
// Prompt history: the concept's `#prompt/<id>` is production's Library history of the linked positive.
|
||||
expect.soft(concept.history,'concept history').toBe('#prompt/alpine');
|
||||
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
|
||||
});
|
||||
|
||||
// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its
|
||||
// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its
|
||||
// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags).
|
||||
test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProductionPanel(page,'dark');
|
||||
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
|
||||
const rows=panelOf(page).locator('.detail-inner > .tags');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']);
|
||||
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
|
||||
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
|
||||
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
|
||||
test('inspector comparison detects mutations',async({page})=>{
|
||||
test.setTimeout(120_000);
|
||||
const viewport={width:1600,height:1100};
|
||||
await page.setViewportSize(viewport);
|
||||
await openConceptPanel(page,'dark');
|
||||
const reference=await inventory(page,true);
|
||||
await openProductionPanel(page,'dark');
|
||||
const {viewerDetails,fileControls}=await inventory(page);
|
||||
const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails,fileControls});
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/);
|
||||
await restoreCss();await settle(page);
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
const panel=(await inspectorOf(page)).panel;
|
||||
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
|
||||
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
|
||||
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
|
||||
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
|
||||
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
|
||||
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
||||
|
||||
await mutate(page,{remove:['#image-inspector > .detail-footer']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||
|
||||
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
||||
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
||||
// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six
|
||||
// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100
|
||||
// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.library-grid .prompt-card';
|
||||
const CONCEPT_PROMPTS=6;
|
||||
|
||||
/** Recorded Library deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
export function mapLibraryConcept(reference) {
|
||||
const library=reference.library;
|
||||
// D16 (follow-up PR): no Save view until the saved-views store ships; the remaining items flow again (`reflow`).
|
||||
const toolbar=withoutItems(library.toolbar,item=>item.text==='Save view');
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
|
||||
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
|
||||
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
|
||||
return item;
|
||||
});
|
||||
// A shorter toolbar moves what follows it up by as much.
|
||||
const rise=library.toolbar.height-toolbar.height;
|
||||
const cards=library.cards.map((card,index)=>({...card,
|
||||
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
|
||||
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
|
||||
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
|
||||
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
|
||||
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
||||
tags:sortedTags(card.tags),
|
||||
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
||||
// run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is
|
||||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||||
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
|
||||
return {...reference,library:{...library,toolbar,
|
||||
// D35: the caption uses the singular for one.
|
||||
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
|
||||
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
|
||||
// D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none.
|
||||
trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}};
|
||||
}
|
||||
|
||||
/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */
|
||||
const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }';
|
||||
const CLAMP_LINES=2;
|
||||
/**
|
||||
* D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the
|
||||
* text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the
|
||||
* grid's rows then close up by their tallest card's loss.
|
||||
*/
|
||||
export function clampedCards({grid,cards}) {
|
||||
const shrunk=cards.map(card=>{
|
||||
const text=card.section.text;
|
||||
if(text.lines<=CLAMP_LINES)return {card,loss:0};
|
||||
const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight));
|
||||
return {loss,card:{...card,height:card.height-loss,
|
||||
content:{...card.content,height:card.content.height-loss},
|
||||
section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}},
|
||||
meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss},
|
||||
footer:{...card.footer,top:card.footer.top-loss}}};
|
||||
});
|
||||
// Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above.
|
||||
const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b);
|
||||
const rowLoss=top=>{
|
||||
const row=cards.flatMap((card,index)=>card.top===top?[index]:[]);
|
||||
return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height));
|
||||
};
|
||||
const above=top=>tops.filter(other=>other<top).reduce((sum,other)=>sum+rowLoss(other),0);
|
||||
return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)},
|
||||
cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))};
|
||||
}
|
||||
|
||||
/** D43: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */
|
||||
export function sortedTags(tags) {
|
||||
return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text)));
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','library','noPageScroll'];
|
||||
function compare(reference,actual,{sections=SECTIONS,library=null}={}) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library
|
||||
?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]]));
|
||||
return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertLibraryMatch(reference,actual) {
|
||||
const diff=compare(reference,actual);
|
||||
if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
// Every card image decoded, so nothing moves while it is measured.
|
||||
const decoded=page=>page.evaluate(async()=>{
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
|
||||
});
|
||||
|
||||
const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant';
|
||||
/**
|
||||
* The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural
|
||||
* reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own
|
||||
* layout. The concept page is restored afterwards.
|
||||
*/
|
||||
async function conceptInventory(page) {
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await page.evaluate(({plural,singular})=>{
|
||||
window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural);
|
||||
for(const chip of window.__conceptPlurals)chip.textContent=singular;
|
||||
},{plural:PLURAL_ONE,singular:SINGULAR_ONE});
|
||||
const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta);
|
||||
await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE);
|
||||
return {...reference,oracle:{meta}};
|
||||
}
|
||||
|
||||
/** The concept page with each card's tag chips in alphabetical order (the D43 oracle); returns the undo. */
|
||||
async function sortConceptTags(page) {
|
||||
await page.evaluate(()=>{
|
||||
const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return x<y?-1:x>y?1:0;};
|
||||
window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{
|
||||
const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];});
|
||||
});
|
||||
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
|
||||
}
|
||||
|
||||
async function openConceptLibrary(page,theme,motion) {
|
||||
await openConcept(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
async function openProductionLibrary(page,theme,motion) {
|
||||
await openProduction(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
|
||||
// The category list is read once per visit; it is part of the toolbar inventory.
|
||||
await expect(page.locator('#category-filter option')).toHaveCount(5);
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
|
||||
const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents();
|
||||
const captionOf=page=>page.locator('.section-caption > span').first().textContent();
|
||||
|
||||
/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */
|
||||
async function filteredOf(page,apply,undo) {
|
||||
const before=await captionOf(page);
|
||||
await apply();
|
||||
await expect.poll(()=>captionOf(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
|
||||
const result={caption:await captionOf(page),titles:await titlesOf(page)};
|
||||
await undo();
|
||||
await expect.poll(()=>captionOf(page)).toBe(before);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */
|
||||
async function historyFrom(page,control,back) {
|
||||
const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim();
|
||||
await page.locator(CARDS).first().locator(control).click();
|
||||
const h1=page.locator('h1#page-title');
|
||||
await expect(h1).toHaveText(title);
|
||||
const result={title,h1:(await h1.textContent()).trim()};
|
||||
await back();
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */
|
||||
async function behaviorsOf(page,{back,sent}) {
|
||||
const first=page.locator(CARDS).first();
|
||||
const title=await historyFrom(page,'.card-title > button',back);
|
||||
const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back);
|
||||
// The footer send opens the sender with the positive prompt only.
|
||||
const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim();
|
||||
await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click();
|
||||
const send={positive,roles:await sent(page)};
|
||||
// Copy writes the full positive and shows the toast.
|
||||
await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click();
|
||||
await expect(page.locator('.toast')).not.toHaveText('');
|
||||
const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))};
|
||||
const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill(''));
|
||||
const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption(''));
|
||||
return {title,explore,send,copy,search,category};
|
||||
}
|
||||
|
||||
/** The concept's demo sender: the roles its preview lists, with their text. */
|
||||
async function conceptSent(page) {
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
|
||||
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return roles;
|
||||
}
|
||||
|
||||
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
|
||||
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('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());
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
|
||||
return roles;
|
||||
};
|
||||
}
|
||||
async function openHost(context) {
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.previewed=[];
|
||||
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
|
||||
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
|
||||
apply:roles=>({targetId:target.id,values:roles}),
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{
|
||||
testInfo.manifest=await seedConcept(request);
|
||||
// Both documents copy through the clipboard; the stub records what they wrote.
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const narrow=width<=700;
|
||||
const host=await openHost(context);
|
||||
try {
|
||||
const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
await openConceptLibrary(page,theme,motion);
|
||||
const reference=await conceptInventory(page);
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without Save view (D16).
|
||||
const restore=await mutate(page,{remove:['.toolbar > [data-action="save-view"]']});
|
||||
const removed=(await inventoryOf(page,'#main')).library.toolbar;
|
||||
await restore();
|
||||
// ...and with the D23 clamp applied to its cards.
|
||||
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
|
||||
const clamped=(await inventoryOf(page,'#main')).library;
|
||||
await restoreClamp();
|
||||
// ...and with every card's tag chips put in alphabetical order (D43).
|
||||
const restoreTags=await sortConceptTags(page);
|
||||
const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags);
|
||||
await restoreTags();
|
||||
const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null;
|
||||
|
||||
await openProductionLibrary(page,theme,motion);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
|
||||
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
|
||||
result[motion]={reference,removed,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,{reference,removed,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||||
const {grid,cards,toolbar}=reference.library,card=cards[0];
|
||||
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
|
||||
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
|
||||
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
|
||||
const art=narrow?150:170;
|
||||
expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'});
|
||||
const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat);
|
||||
expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1);
|
||||
expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]);
|
||||
const pad=narrow?'15px':'18px';
|
||||
expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad});
|
||||
expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'});
|
||||
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
|
||||
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
|
||||
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
|
||||
expect.soft(diffInventory(stripExact(withoutItems(toolbar,item=>item.text==='Save view')),stripExact(removed)),`${motion} toolbar without Save view`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
if(!concept)continue;
|
||||
// Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes
|
||||
// the full positive with the concept's toast; search and category narrow the shelf alike.
|
||||
expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text});
|
||||
expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive});
|
||||
expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive});
|
||||
expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']);
|
||||
// D35: the shelf caption uses the singular for one.
|
||||
const counted=filter=>({...filter,caption:countCopy(filter.caption)});
|
||||
expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)});
|
||||
}
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
|
||||
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1800,height:1100});
|
||||
await openConceptLibrary(page,theme,'no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`);
|
||||
await openProductionLibrary(page,theme,'no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`);
|
||||
expect(reference.library.grid.columns,'concept columns').toBe(3);
|
||||
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
|
||||
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
|
||||
test('library comparison detects mutations',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConceptLibrary(page,'dark','no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await openProductionLibrary(page,'dark','no-preference');
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertLibraryMatch(reference,await measure());
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/);
|
||||
await restoreCss();await settle(page);
|
||||
assertLibraryMatch(reference,await measure());
|
||||
|
||||
await mutate(page,{remove:['.card-meta .chip.red']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/);
|
||||
});
|
||||
@@ -19,7 +19,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
|
||||
await page.setViewportSize({width,height});const f=page.fixture;
|
||||
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-history .timeline-panel':'dialog.library-editor');
|
||||
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
|
||||
|
||||
@@ -2,7 +2,7 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mj
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
|
||||
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
|
||||
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Inspect /}).click();
|
||||
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
@@ -58,8 +58,8 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0);
|
||||
const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope);
|
||||
const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json();
|
||||
await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text);
|
||||
await preview.getByRole('button',{name:'Send prompt pair',exact:true}).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('Sent to ComfyUI.');
|
||||
await select(page,'0:shared/same.png');await page.locator('.media-card.selected .image-open').click();await expect(preview).toContainText(evidence.prompts.positive.text);
|
||||
await preview.getByRole('button',{name:/^Send prompt pair 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('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]);
|
||||
} finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
@@ -67,17 +67,18 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
|
||||
await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
|
||||
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
|
||||
await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
|
||||
});
|
||||
|
||||
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:empty-negative.png');
|
||||
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
|
||||
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
|
||||
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toBeEnabled();
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
|
||||
// Every inspector send is named after its prompt ("…to ComfyUI: <title>"); an untitled one ends at "ComfyUI".
|
||||
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI(: |$)/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
|
||||
@@ -100,7 +101,7 @@ test('inspection failures retry and a delayed previous selection cannot replace
|
||||
test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
|
||||
@@ -113,7 +114,7 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('light');
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
|
||||
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await page.locator('.media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
|
||||
await page.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
@@ -137,7 +138,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true]);
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
|
||||
await link.click();
|
||||
const sender=await chooseDestination(page);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
|
||||
@@ -157,7 +158,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
|
||||
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
|
||||
const host=await fakeHost(context,page,{rejectApply:true});
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
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();
|
||||
@@ -167,9 +168,9 @@ test('host apply rejection retains the current text and requires a new preview',
|
||||
|
||||
test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await page.locator('.media-card.selected .image-open').click();
|
||||
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
|
||||
const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
|
||||
const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});await send.click();await expect(dialog(page)).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
|
||||
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
@@ -178,7 +179,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:empty-negative.png');
|
||||
await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
|
||||
await details(page).getByRole('button',{name:/^Send negative 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('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
|
||||
@@ -187,7 +188,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',exact:true}).click();
|
||||
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 host=await fakeHost(context,page);
|
||||
try {
|
||||
@@ -203,12 +204,13 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy
|
||||
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
|
||||
await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
|
||||
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
|
||||
const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).toHaveText(text);
|
||||
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
|
||||
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
|
||||
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
|
||||
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);
|
||||
await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
|
||||
await page.getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
|
||||
});
|
||||
@@ -277,20 +279,37 @@ test('unresolved roles name the blocking node as text and offer no prompt action
|
||||
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
|
||||
test('prompt role headings contain the concept label and compact real actions without a source line',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
for (const name of ['Positive','Negative']) {
|
||||
const block=role(details(page),name),heading=block.locator('.prompt-block-heading');
|
||||
await expect(heading.getByRole('heading',{name:`${name} prompt`,exact:true})).toBeVisible();
|
||||
await expect(heading.getByRole('button',{name:`Copy ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
|
||||
await expect(heading.getByRole('button',{name:`Send ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
|
||||
for (const [name,label] of [['Positive','+ Positive prompt'],['Negative','\u2212 Negative prompt']]) {
|
||||
const block=role(details(page),name),heading=block.locator('.prompt-heading');
|
||||
await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`));
|
||||
await expect(heading.locator('span.role-label')).toHaveText(label);
|
||||
await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`));
|
||||
await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`));
|
||||
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
|
||||
expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true);
|
||||
expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true);
|
||||
}
|
||||
await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow');
|
||||
await expect(details(page)).not.toContainText('Inferred from the saved workflow');
|
||||
await select(page,'0:empty-negative.png');
|
||||
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
|
||||
await expect(role(details(page),'Negative').locator('.prompt-block-heading [data-copy-role]')).toHaveCount(1);
|
||||
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(role(details(page),'Negative').locator('.prompt-heading [data-copy-role]')).toHaveCount(1);
|
||||
await select(page,'0:uncertain.png');await expect(details(page).locator('[data-copy-role]')).toHaveCount(0);
|
||||
await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await expect(details(page).locator('p.prompt-text.muted').first()).toHaveText('Not recorded in this file.');
|
||||
});
|
||||
|
||||
test('copy toast and fallback dialog',async({page})=>{
|
||||
await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});});
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent();
|
||||
const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]);
|
||||
await expect(positive.getByRole('status')).toHaveCount(0);
|
||||
await page.evaluate(()=>{window.clipboardFails=true;});await copy.click();
|
||||
const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback).toBeVisible();
|
||||
const area=fallback.getByRole('textbox',{name:'Select and copy this text',exact:true});
|
||||
await expect(area).toHaveValue(text);await expect(area).toBeFocused();
|
||||
expect(await area.evaluate(node=>[node.readOnly,node.selectionStart,node.selectionEnd])).toEqual([true,0,text.length]);
|
||||
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
|
||||
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -76,6 +76,53 @@ class TestModel:
|
||||
return ["generated", Path(image_path).parent.parent.name]
|
||||
|
||||
|
||||
REVEAL_OUTCOMES = ("opened", "unavailable", "fail")
|
||||
|
||||
|
||||
def register_reveal_stub(routes, api, root: Path):
|
||||
"""D38: Show in folder never opens a real file manager here.
|
||||
|
||||
The API's opener records the resolved path; ``POST /__test__/reveal``
|
||||
sets the next outcome and ``GET /__test__/reveal`` lists the opened paths.
|
||||
"""
|
||||
from utils.file_reveal import OpenerUnavailable, RevealGate
|
||||
|
||||
state = {"outcome": "opened", "opened": []}
|
||||
|
||||
def opener(path: Path, gallery_root: Path) -> None:
|
||||
if state["outcome"] == "unavailable":
|
||||
raise OpenerUnavailable()
|
||||
if state["outcome"] == "fail":
|
||||
raise OSError("stubbed start failure")
|
||||
state["opened"].append(Path(path).relative_to(root.resolve()).as_posix())
|
||||
|
||||
api._reveal_opener = opener
|
||||
# Each click gets an answer here; the cooldown is a unit-tested default.
|
||||
api._reveal_gate = RevealGate(cooldown=0)
|
||||
|
||||
@routes.post("/__test__/reveal")
|
||||
async def set_reveal_outcome(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"outcome"}
|
||||
or data["outcome"] not in REVEAL_OUTCOMES
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
state["outcome"] = data["outcome"]
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/reveal")
|
||||
async def reveal_calls(request: web.Request) -> web.Response:
|
||||
return web.json_response({"opened": state["opened"]})
|
||||
|
||||
def reset() -> None:
|
||||
state["outcome"] = "opened"
|
||||
state["opened"].clear()
|
||||
|
||||
return types.SimpleNamespace(reset=reset)
|
||||
|
||||
|
||||
def create_app(root: Path) -> web.Application:
|
||||
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
|
||||
os.chdir(root)
|
||||
@@ -111,6 +158,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
|
||||
async def canonical_asset(request: web.Request) -> web.Response:
|
||||
filename, content_type = CANONICAL_ASSETS[request.path]
|
||||
@@ -311,6 +359,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_fixture = manifest
|
||||
return web.json_response({"success": True, **manifest})
|
||||
history_commands.reset()
|
||||
reveal_stub.reset()
|
||||
workspace_visual = data.get("workspace_visual", False)
|
||||
full_inspector = data.get("full_inspector", False)
|
||||
extra = data.get("extra_images", 0)
|
||||
@@ -408,6 +457,7 @@ def create_app(root: Path) -> web.Application:
|
||||
raise web.HTTPBadRequest()
|
||||
history_commands.reset()
|
||||
library_editor_commands.reset()
|
||||
reveal_stub.reset()
|
||||
history_fixture = None
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_concept(
|
||||
|
||||
@@ -220,13 +220,13 @@ test('the concept fixture renders the concept inspector facts',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProduction(page,'images','dark');
|
||||
// The first image is the concept's first sample, selected into the panel on first load (D19).
|
||||
const facts=page.locator('#workspace-main .generation-facts');
|
||||
const facts=page.locator('#workspace-main #image-inspector .meta-grid');
|
||||
await expect(facts).toBeVisible();
|
||||
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
|
||||
// app.js:404-408: the concept's facts for alpine_00001.png.
|
||||
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
|
||||
// D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way.
|
||||
await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible();
|
||||
// D21: execution evidence is the concept's "Execution recorded" badge.
|
||||
await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded');
|
||||
});
|
||||
|
||||
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
|
||||
|
||||
@@ -9,8 +9,9 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
|
||||
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
|
||||
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
|
||||
// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery.
|
||||
const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');};
|
||||
// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the
|
||||
// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter).
|
||||
const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');};
|
||||
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
|
||||
const exact=async locator=>locator.evaluate(node=>node.textContent);
|
||||
|
||||
@@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is
|
||||
|
||||
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
|
||||
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
|
||||
await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
|
||||
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
|
||||
try {
|
||||
@@ -128,10 +129,10 @@ test('archive delete merge and prune preserve frozen evidence without repairing
|
||||
});
|
||||
|
||||
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original);
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(original);
|
||||
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
|
||||
await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID');
|
||||
expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404);
|
||||
@@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
|
||||
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
|
||||
await open(page,fixture.positive_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
|
||||
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
|
||||
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
|
||||
const selected=detail.variants.find(v=>v.negative_text==='A negative').title;
|
||||
await page.getByRole('button',{name:`Select negative ${selected}`,exact:true}).click();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
|
||||
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
|
||||
@@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
|
||||
await expect(marker(page,id)).toBeFocused();
|
||||
}
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
|
||||
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(page.getByRole('button',{name:`Select negative ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
|
||||
});
|
||||
|
||||
@@ -199,18 +200,20 @@ test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode a
|
||||
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
|
||||
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
|
||||
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
|
||||
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
|
||||
expect(await exact(pos.locator('p.prompt-text'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.raw);
|
||||
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.effective);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('p.prompt-text'))).toBe(' \n\t ');
|
||||
// D22: the frozen block has no source line; each field's source stays in "Frozen field provenance".
|
||||
await expect(unknown).not.toContainText('host_cache_output');await expect(dialog(page).locator('.recorded-provenance')).toContainText(/Effective · \w+ · host_cache_output/);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
|
||||
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');
|
||||
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
|
||||
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
|
||||
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
|
||||
@@ -280,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me
|
||||
});
|
||||
|
||||
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',exact:true}).click();
|
||||
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
|
||||
let writes=0,galleryReads=0,outputReads=0;
|
||||
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
|
||||
@@ -299,7 +302,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(page.getByRole('button',{name:'Select negative Saved empty',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
|
||||
});
|
||||
|
||||
@@ -397,7 +400,7 @@ test('history, run and output failures retry their own reads and route exit igno
|
||||
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
|
||||
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
|
||||
await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
|
||||
await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
|
||||
@@ -447,7 +450,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
||||
});
|
||||
try {
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
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();
|
||||
@@ -466,16 +469,16 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible();
|
||||
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
|
||||
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
|
||||
@@ -562,7 +565,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run
|
||||
});
|
||||
|
||||
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
@@ -573,8 +576,8 @@ test('history epoch recovery reports saved-parent read failures and retries with
|
||||
});
|
||||
|
||||
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
|
||||
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text'));
|
||||
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
|
||||
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
@@ -583,7 +586,7 @@ test('changed saved-owner identity is disclosed without silently relabeling or r
|
||||
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('p.prompt-text'))).toBe(original);
|
||||
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
|
||||
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
|
||||
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
|
||||
@@ -593,7 +596,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified
|
||||
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
|
||||
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
|
||||
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
|
||||
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
|
||||
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
@@ -606,7 +609,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
|
||||
await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
|
||||
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();
|
||||
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();
|
||||
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
@@ -615,7 +618,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation');
|
||||
await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0);
|
||||
await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden();
|
||||
await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
await expect(choices.first()).toBeEnabled();await expect(page.locator('.variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
|
||||
const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
@@ -623,15 +626,15 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
async function retainSavedSelection(page,request,fixture) {
|
||||
await open(page,fixture.detail_id);
|
||||
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
|
||||
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
|
||||
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
|
||||
const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title;
|
||||
await page.getByRole('button',{name:`Select negative ${name}`,exact:true}).click();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'));
|
||||
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'));
|
||||
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
|
||||
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
|
||||
return async()=>{
|
||||
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative);
|
||||
await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative);
|
||||
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
|
||||
};
|
||||
}
|
||||
@@ -702,7 +705,7 @@ test('capture-only recovery uses the changed real output capture without re-read
|
||||
const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete');
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting');
|
||||
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
|
||||
});
|
||||
|
||||
test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{
|
||||
@@ -719,3 +722,240 @@ test('completed page one stays bounded across mode changes until explicit output
|
||||
await page.keyboard.press('Escape');await retained();
|
||||
} finally {releaseNext();}
|
||||
});
|
||||
|
||||
// #249 PR F1: the concept heading (app.js a591–597) and summary surface (a598) with real counts (D24).
|
||||
const historyHeading=page=>page.locator('#workspace-main > .heading');
|
||||
const summary=page=>page.locator('#workspace-main .library-history article.surface');
|
||||
test('history heading actions stay on one row at 1600 and are enabled after load',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const f=page.fixture;
|
||||
let releaseDetail;const held=new Promise(resolve=>{releaseDetail=resolve;});
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.positive_id}\\?`),async route=>{await held;await route.continue().catch(()=>{});});
|
||||
try {
|
||||
await page.goto(`/prompt_manager/workspace#/library/${f.positive_id}`);
|
||||
const heading=historyHeading(page),send=heading.locator('.heading-actions > button.send');
|
||||
await expect(heading.locator('p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
// D24: disabled only while the selected pair's roles are loading.
|
||||
await expect(send).toBeDisabled();releaseDetail();
|
||||
await expect(recorded(page)).toBeVisible();await expect(send).toBeEnabled();
|
||||
} finally {releaseDetail();}
|
||||
const heading=historyHeading(page),title=await heading.locator('h1').textContent();
|
||||
const actions=heading.locator('.heading-actions > button');
|
||||
await expect(actions).toHaveCount(3);
|
||||
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()))).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
|
||||
await expect(actions.nth(2)).toHaveClass('send');
|
||||
await expect(actions.nth(2)).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${title}`);
|
||||
for(const action of [actions.nth(0),actions.nth(1)]) await expect(action).toBeEnabled();
|
||||
const boxes=await actions.evaluateAll(nodes=>nodes.map(node=>{const box=node.getBoundingClientRect();return {y:box.y,height:box.height};}));
|
||||
expect(new Set(boxes.map(box=>box.y)).size).toBe(1);
|
||||
const row=await heading.locator('.heading-actions').evaluate(node=>node.getBoundingClientRect().height);
|
||||
expect(row).toBe(boxes[0].height);
|
||||
});
|
||||
test('summary meta uses recorded runs',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.positive_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${f.positive_id}`)).json(),runs=(await historyRead(request,f.positive_id)).recorded_run_count;
|
||||
const surface=summary(page),title=await historyHeading(page).locator('h1').textContent();
|
||||
await expect(surface).toHaveCount(1);
|
||||
await expect(surface.locator(':scope > span.chip')).toHaveText(detail.positive.category || 'Uncategorized');
|
||||
const plural=(count,word)=>`${count} ${count===1?word:`${word}s`}`;
|
||||
await expect(surface.locator(':scope > .card-meta > span')).toHaveText([plural(runs,'tracked run'),plural(detail.positive.image_count,'image'),plural(detail.positive.variant_count,'negative variant')]);
|
||||
// The concept order: category chip, positive block, selected negative block, then the meta line.
|
||||
expect(await surface.evaluate(node=>[...node.querySelectorAll('.chip, .prompt-section, .card-meta')].filter(item=>item.checkVisibility()).map(item=>item.className))).toEqual(['chip','prompt-section positive','prompt-section negative','card-meta']);
|
||||
for(const name of ['Copy positive prompt','Send positive prompt to ComfyUI','Copy negative prompt','Send negative prompt to ComfyUI'])
|
||||
await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible();
|
||||
await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px');
|
||||
});
|
||||
|
||||
// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26).
|
||||
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
|
||||
const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true});
|
||||
test('variants panel matches the concept',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await expect(panel).toHaveClass('surface timeline-panel');
|
||||
const heading=panel.locator(':scope > .panel-heading');
|
||||
await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants');
|
||||
await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.');
|
||||
const actions=heading.locator(':scope > .row-actions > button');
|
||||
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']);
|
||||
const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
const timeline=panel.locator(':scope > .timeline');
|
||||
await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged');
|
||||
const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3);
|
||||
// Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i.
|
||||
const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs'];
|
||||
for(const [index,name] of names.entries()) {
|
||||
const lane=lanes.nth(index);
|
||||
await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`);
|
||||
const dot=lane.locator(':scope > button.variant-dot');
|
||||
await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`);
|
||||
await expect(dot).toHaveAttribute('aria-pressed',String(index===0));
|
||||
await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`);
|
||||
expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']);
|
||||
}
|
||||
await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false');
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2);
|
||||
const lower=panel.locator(':scope > .timeline-lower');
|
||||
await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run');
|
||||
await expect(lower.locator(':scope > button')).toHaveText('All images for this positive');
|
||||
await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/);
|
||||
await expect(panel).not.toContainText('Saved combinations, not an execution timeline.');
|
||||
// The concept tokens reach the lanes (style.css 1096-1137).
|
||||
await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px');
|
||||
await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%');
|
||||
await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px');
|
||||
});
|
||||
test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2);
|
||||
await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0);
|
||||
// D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target.
|
||||
for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) {
|
||||
const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]);
|
||||
expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x);
|
||||
}
|
||||
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded();
|
||||
const reach=await dot.evaluate(node=>{
|
||||
const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2;
|
||||
return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;});
|
||||
});
|
||||
expect(reach).toEqual(['dot','dot','dot','dot']);
|
||||
}
|
||||
// The baseline has a recorded execution: the dot opens Recorded run for it.
|
||||
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click();
|
||||
await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
|
||||
// Clean geometry was counted but never recorded: Repeat run shows its newest linked image only.
|
||||
const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true});
|
||||
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
|
||||
await clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
|
||||
await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.');
|
||||
await expect(repeat.locator('#modal-body > .spaced-15 article[data-image-key]')).toHaveCount(1);
|
||||
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('limit')).toBe('1');
|
||||
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused();
|
||||
});
|
||||
test('all images dialog lists every image for the positive',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
||||
// Opening on the first lane, the shared gallery shows that lane only.
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
|
||||
await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible();
|
||||
await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
|
||||
// Leaving the route closes an open dialog and its gallery.
|
||||
await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
|
||||
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0);
|
||||
});
|
||||
test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
const compare=panel.getByRole('button',{name:'Compare negatives',exact:true});
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section).toHaveCount(0);
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');
|
||||
// The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25).
|
||||
await expect(section).toHaveCount(1);
|
||||
expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']);
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.');
|
||||
const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2);
|
||||
await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark');
|
||||
await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0);
|
||||
// D46: the two blocks are named apart, so no region or action name repeats on the page.
|
||||
await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await expect(section.locator('.diff-added')).toHaveCount(0);
|
||||
// Another dot updates the same section in place.
|
||||
const node=await section.elementHandle();
|
||||
await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.');
|
||||
await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light');
|
||||
await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']);
|
||||
await expect(section.locator(':scope > p.small')).toHaveCount(1);
|
||||
expect(await node.evaluate(element=>element.isConnected)).toBe(true);
|
||||
await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block');
|
||||
await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px');
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0);
|
||||
});
|
||||
test('compare waits for the page holding the oldest active negative',async({page})=>{
|
||||
const f=page.fixture;
|
||||
// A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46).
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{
|
||||
const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue();
|
||||
const response=await route.fetch(),body=await response.json();
|
||||
await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}});
|
||||
});
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await panel.getByRole('button',{name:'Compare negatives',exact:true}).click();
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.');
|
||||
await expect(section.locator('.compare-grid')).toHaveCount(0);
|
||||
await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`));
|
||||
});
|
||||
test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{
|
||||
const f=page.fixture;
|
||||
await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}}));
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});
|
||||
await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused();
|
||||
await page.unroute(/\/history\/pairs\/\d+\/newest/);
|
||||
// The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast.
|
||||
await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}}));
|
||||
await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
|
||||
await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';});
|
||||
const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url()));
|
||||
await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
|
||||
expect((await(await newest).json()).run.run_id).toBe(f.canonical_run);
|
||||
await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
});
|
||||
test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{
|
||||
const f=page.fixture;
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK();
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click();
|
||||
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
|
||||
await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click();
|
||||
const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1);
|
||||
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived');
|
||||
// Each open replaces the previous dialog and its gallery.
|
||||
await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
});
|
||||
test('Compare negatives is disabled without an active variant',async({page,request})=>{
|
||||
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK();
|
||||
await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true});
|
||||
await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0);
|
||||
});
|
||||
test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{
|
||||
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id]));
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK();
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK();
|
||||
await open(page,f.canonical_id);
|
||||
const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']);
|
||||
});
|
||||
|
||||
@@ -6,15 +6,20 @@ const workspace = '/prompt_manager/workspace#/images';
|
||||
const cards = page => page.locator('article[data-image-key]');
|
||||
// Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing.
|
||||
const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1';
|
||||
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
|
||||
const details = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
// The concept panel names no source (D20); its cover is the selected file's own rooted URL, root 0 output, 1 input.
|
||||
const SOURCE_ROOTS = { output: 0, input: 1 };
|
||||
const expectPanelSource = (page, source) => expect(details(page).locator('img.detail-cover')).toHaveAttribute('src', new RegExp(`[?&]root=${SOURCE_ROOTS[source]}(&|$)`));
|
||||
// The concept toolbar, filter row and caption (#249 C2); Source, Folder, Media and Tags live in "Refine this view".
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
|
||||
const caption = page => page.locator('.section-caption');
|
||||
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
|
||||
const importDialog = page => page.getByRole('dialog', { name: 'Import & inspect', exact: true });
|
||||
// D44: the Filters button reads "Filters · N" while N Refine filters are active.
|
||||
const filtersButton = page => page.locator('#workspace-main search.toolbar').getByRole('button', { name: /^Filters(?: · \d+)?$/ });
|
||||
async function refine(page, values) {
|
||||
await page.getByRole('button', { name: 'Filters', exact: true }).click();
|
||||
await filtersButton(page).click();
|
||||
const dialog = refineDialog(page);
|
||||
for (const [label, value] of Object.entries(values)) {
|
||||
const control = dialog.getByLabel(label, { exact: true });
|
||||
@@ -57,16 +62,15 @@ test('native Images keeps colliding filenames distinct and inspects the selected
|
||||
expect(new Set(originals).size).toBe(2);
|
||||
await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click();
|
||||
await expect(details(page)).toContainText('same.png');
|
||||
await expect(details(page)).toContainText('shared/same.png');
|
||||
await expect(details(page)).toContainText(/input/i);
|
||||
await expectPanelSource(page, 'input');
|
||||
await capture('after-images-source-collision-input-1600');
|
||||
await cards(page).first().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
|
||||
await expect(details(page)).toContainText(/output/i);
|
||||
await expectPanelSource(page, 'output');
|
||||
await capture('after-images-source-collision-output-1600');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
for (const [index,source] of [[1,'input'],[0,'output']]) {
|
||||
await cards(page).nth(index).getByRole('button',{name:'Inspect same.png',exact:true}).click();
|
||||
await expect(details(page)).toContainText(new RegExp(source,'i'));
|
||||
await expectPanelSource(page, source);
|
||||
await details(page).scrollIntoViewIfNeeded();
|
||||
await capture(`after-images-source-collision-${source}-390`);
|
||||
}
|
||||
@@ -92,17 +96,16 @@ test('image selection is shown on the selected card', async ({ page }) => {
|
||||
await expect(cards(page).locator('.image-card-actions [aria-pressed]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('closing details preview restores its trigger focus', async ({ page }) => {
|
||||
test('the side panel cover is the concept plain image, never a preview control', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true });
|
||||
await selected.click();
|
||||
const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true });
|
||||
await preview.click();
|
||||
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
|
||||
await expectPanelSource(page, 'input');
|
||||
// D19/D20: the card image opens the viewer; the panel cover is decoration (`alt=""`) and not focusable.
|
||||
await expect(details(page).getByRole('button', { name: 'Open full preview', exact: true })).toHaveCount(0);
|
||||
await expect(details(page).locator('img.detail-cover')).toHaveAttribute('alt', '');
|
||||
await details(page).locator('img.detail-cover').click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(preview).toBeFocused();
|
||||
});
|
||||
|
||||
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
|
||||
@@ -226,7 +229,7 @@ test('layout changes retain selection and use one collection in Grid, List and M
|
||||
await page.getByRole('button', { name: layout, exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(details(page)).toContainText(/input/i);
|
||||
await expectPanelSource(page, 'input');
|
||||
const boxes = await cards(page).evaluateAll(elements => elements.map(element => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: box.x, y: box.y, right: box.right, bottom: box.bottom };
|
||||
@@ -265,8 +268,9 @@ test('returning to Images restores the filtered stream, layout, selection and sc
|
||||
await expect(cards(page)).toHaveCount(80);
|
||||
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
|
||||
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120);
|
||||
// Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view.
|
||||
await page.getByRole('button', { name: 'Filters', exact: true }).click();
|
||||
// Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view. The restored Source
|
||||
// still counts (D44).
|
||||
await page.getByRole('button', { name: 'Filters · 1', exact: true }).click();
|
||||
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('0');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(refineDialog(page)).toBeHidden();
|
||||
@@ -520,7 +524,7 @@ async function staleSourceRecovery(page, request, roots) {
|
||||
await page.goto(workspace);
|
||||
const source = refineDialog(page).getByLabel('Source', { exact: true });
|
||||
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
|
||||
const filters = page.getByRole('button', { name: 'Filters', exact: true });
|
||||
const filters = filtersButton(page);
|
||||
await filters.click();await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape');
|
||||
await refine(page, { Source: '0', Folder: 'bulk' });
|
||||
const configured = page.waitForResponse(response => {
|
||||
@@ -565,7 +569,9 @@ for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['i
|
||||
expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]);
|
||||
await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders');
|
||||
for (const name of ['Filter by category', 'Sort results']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
|
||||
for (const name of ['Filters', 'Import']) await expect(page.getByRole('button', { name, exact: true })).toBeDisabled();
|
||||
// D44: recovery clears Source and Folder; the Media filter stays and still counts.
|
||||
await expect(recovery.filters).toHaveText('Filters · 1');await expect(recovery.filters).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeDisabled();
|
||||
await expect(search(page)).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.filters.evaluate(node => node.click());await sortBy(page).dispatchEvent('change');
|
||||
@@ -611,7 +617,7 @@ test('mobile Images keeps filters, layouts and selected details within the viewp
|
||||
for (const layout of ['List', 'Masonry', 'Grid']) {
|
||||
await page.getByRole('button', { name: layout, exact: true }).click();
|
||||
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
|
||||
await expect(details(page)).toContainText(/input/i);
|
||||
await expectPanelSource(page, 'input');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
}
|
||||
await expect(search(page)).toBeVisible();
|
||||
@@ -747,10 +753,9 @@ test('Images uses approved geometry while keeping real controls', async ({page,r
|
||||
expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(1.33,2);
|
||||
expect((await caption(page).boundingBox()).y).toBeLessThan(geometry[0].y);
|
||||
await expect(cards(page).first()).toHaveCSS('box-shadow',/ 0px 0px 0px 1px$/);
|
||||
const cover=details(page).getByRole('button',{name:'Open full preview',exact:true});
|
||||
await expect(cover).toHaveCSS('height','150px');
|
||||
await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px');
|
||||
expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='BUTTON')).toBe(true);
|
||||
const cover=details(page).locator('img.detail-cover');
|
||||
await expect(cover).toBeVisible();await expect(cover).toHaveCSS('height','150px');
|
||||
expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='IMG')).toBe(true);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0);
|
||||
await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0);
|
||||
@@ -830,10 +835,13 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
|
||||
await expect(filtersButton(page)).toHaveText('Filters');
|
||||
await refine(page, { Source: '1', Folder: 'shared', Media: 'image' });
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await expect(row.locator(':scope > span.chip')).toHaveText(`${roots[1].label} · shared`);
|
||||
// D44: Source, Folder and Media each count once; the toolbar category is not a Refine filter.
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 3');
|
||||
await refine(page, { Media: 'video' });
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
await refine(page, { Source: 'all', Folder: '', Media: 'all', Tags: 'cats' });
|
||||
@@ -842,11 +850,13 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
await expect(row.locator(':scope > span.chip')).toHaveText('All output folders');
|
||||
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Filters', exact: true }).click();
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await page.getByRole('button', { name: 'Filters · 1', exact: true }).click();
|
||||
await expect(refineDialog(page).getByLabel('Tags', { exact: true })).toHaveValue('cats');
|
||||
await refineDialog(page).getByRole('button', { name: 'Clear filters', exact: true }).click();
|
||||
await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true })).toHaveValue('');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.getByRole('button', { name: 'Filters', exact: true })).toBeFocused();
|
||||
|
||||
const rescans = [];
|
||||
page.on('request', request => { if (request.url().includes('/prompt_manager/gallery/rescan')) rescans.push(request.method()); });
|
||||
@@ -1014,10 +1024,11 @@ async function cardInventory(page, concept) {
|
||||
const walk = new Function(`return (${walkSource})()`)();
|
||||
return nodes.map(card => {
|
||||
const copy = card.cloneNode(true);
|
||||
// D8: no selection checkbox; D9: no tag button until image annotations. The D7 star is compared.
|
||||
if (concept) for (const node of copy.querySelectorAll('.image-overlays > label, [aria-label^="Tag or auto-tag "]')) node.remove();
|
||||
// D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared.
|
||||
if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove();
|
||||
const open = card.querySelector('.image-open'), img = open.querySelector('img');
|
||||
const star = card.querySelector('.image-overlays > button.star-button');
|
||||
const box = card.querySelector('.image-overlays > label > input');
|
||||
return { filename: open.getAttribute('aria-label').replace(/^Open /, ''),
|
||||
// The concept's alt is the card title plus its demo-only " · sample illustration N" (m5).
|
||||
alt: img.alt.replace(/ · sample illustration \d+$/, ''),
|
||||
@@ -1026,6 +1037,7 @@ async function cardInventory(page, concept) {
|
||||
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
|
||||
inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
|
||||
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
|
||||
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
|
||||
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
|
||||
});
|
||||
}, { walkSource: CARD_PATHS.toString(), concept });
|
||||
@@ -1097,7 +1109,7 @@ test('image click opens the viewer and Info selects the panel', async ({ page })
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// D19: the first load still selects the first image into the panel.
|
||||
await expect(cards(page).first()).toHaveClass(/selected/);
|
||||
await expect(details(page)).toContainText(/output/i);
|
||||
await expectPanelSource(page, 'output');
|
||||
const viewer = page.locator('dialog#viewer-dialog');
|
||||
const image = cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true });
|
||||
await image.click();
|
||||
@@ -1120,10 +1132,10 @@ test('image click opens the viewer and Info selects the panel', async ({ page })
|
||||
await expect(cards(page).nth(1)).toHaveClass(/selected/);
|
||||
await expect(cards(page).first()).not.toHaveClass(/selected/);
|
||||
await expect(info).not.toHaveAttribute('aria-pressed');
|
||||
await expect(details(page)).toContainText(/input/i);
|
||||
await expectPanelSource(page, 'input');
|
||||
await image.focus(); await page.keyboard.press('ArrowLeft');
|
||||
await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
|
||||
await expect(details(page)).toContainText(/input/i);
|
||||
await expectPanelSource(page, 'input');
|
||||
});
|
||||
|
||||
test('grid columns follow concept breakpoints', async ({ page, request }) => {
|
||||
@@ -1187,7 +1199,9 @@ async function zoomedGallery(page, request, { cssZoom }) {
|
||||
});
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-open`)).toBeFocused();
|
||||
// The concept's card order: image, the D7 star, then Inspect.
|
||||
// The concept's card order: image, the D8 checkbox, the D7 star, then Inspect.
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays input[type="checkbox"]`)).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
@@ -1491,8 +1505,8 @@ test('Refine offers only the chosen source folders', async ({ page, request }) =
|
||||
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
// Reopened on that Source, the dialog again lists only its folders.
|
||||
await page.getByRole('button', { name: 'Filters', exact: true }).click();
|
||||
// Reopened on that Source, the dialog again lists only its folders. Source and Folder count (D44).
|
||||
await page.getByRole('button', { name: 'Filters · 2', exact: true }).click();
|
||||
expect(await values()).toEqual(['', 'shared']);
|
||||
});
|
||||
|
||||
@@ -1555,6 +1569,63 @@ test('unreachable sources and categories announce one error', async ({ page }) =
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// PR D-b Task 3 (D42): the shared toast keeps the concept's fixed bottom placement, so a discovery alert that a rescan
|
||||
// leaves visible must not stay under it. The window ends just below the alert, inside the toast's band, as on a short
|
||||
// window or a phone with its browser bars shown.
|
||||
const boxOf = locator => locator.evaluate(node => {
|
||||
const { top, right, bottom, left } = node.getBoundingClientRect();
|
||||
return { top, right, bottom, left };
|
||||
});
|
||||
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||
for (const width of [1600, 390]) {
|
||||
test(`Rescan toast does not cover the discovery alert it leaves at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
const alert = page.getByRole('alert');
|
||||
const failed = 'Categories could not be loaded. Rescan from Import to retry.';
|
||||
await expect(alert).toHaveText(failed);
|
||||
const toastInset = width > 700 ? 22 : 12;
|
||||
await page.setViewportSize({ width, height: Math.ceil((await boxOf(alert)).bottom) + toastInset + 16 });
|
||||
await rescan(page);
|
||||
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
|
||||
await expect(alert).toHaveText(failed);
|
||||
await expect.poll(async () => overlaps(await boxOf(alert), await boxOf(toast(page)))).toBe(false);
|
||||
const [shown, placed] = [await boxOf(alert), await boxOf(toast(page))];
|
||||
expect(shown.top).toBeGreaterThanOrEqual(0);
|
||||
expect(shown.bottom).toBeLessThanOrEqual(placed.top);
|
||||
// The concept's toast stays where the concept puts it: centred, its inset from the bottom.
|
||||
const viewport = page.viewportSize();
|
||||
expect(Math.round(viewport.height - placed.bottom)).toBe(toastInset);
|
||||
expect(Math.abs((placed.left + placed.right) / 2 - viewport.width / 2)).toBeLessThan(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Final review m3: D42 scrolls only when the alert meets the toast's band, never back up to one above the window.
|
||||
test('Rescan does not scroll back to a discovery alert above the window', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
const alert = page.getByRole('alert');
|
||||
await expect(alert).toHaveText('Categories could not be loaded. Rescan from Import to retry.');
|
||||
await page.setViewportSize({ width: 1600, height: 260 });
|
||||
// Import is at the top; the page is scrolled down to the last card while its dialog is open.
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await cards(page).last().evaluate(node => node.scrollIntoView({ block: 'end' }));
|
||||
expect((await boxOf(alert)).bottom).toBeLessThan(0);
|
||||
// The reload itself may move the page; what D42 must not do is scroll the alert back into view.
|
||||
await alert.evaluate(node => {
|
||||
window.alertScrolls = 0;
|
||||
node.scrollIntoView = (...args) => { window.alertScrolls += 1; Element.prototype.scrollIntoView.apply(node, args); };
|
||||
});
|
||||
await importDialog(page).getByRole('button', { name: 'Rescan', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
|
||||
await expect(alert).toBeAttached();
|
||||
expect(await page.evaluate(() => window.alertScrolls)).toBe(0);
|
||||
});
|
||||
|
||||
// #249 final review m8: D36's alphabetical categories hold whatever order the server sends.
|
||||
test('toolbar categories are alphabetical regardless of server order', async ({ page }) => {
|
||||
await page.route('**/prompt_manager/library/options*', route => route.fulfill({
|
||||
@@ -1564,3 +1635,377 @@ test('toolbar categories are alphabetical regardless of server order', async ({
|
||||
const category = page.getByRole('combobox', { name: 'Filter by category', exact: true });
|
||||
await expect(category.locator('option')).toHaveText(['All categories', 'Alpha', 'beta', 'zeta']);
|
||||
});
|
||||
|
||||
// #249 D8 follow-up (image selection, Task 1): the concept's card checkbox and selection bar (app.js 398, 426-434).
|
||||
const bulkbar = page => page.locator('#workspace-main > #bulkbar');
|
||||
const checkbox = (card, filename) => card.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
|
||||
const barButtons = page => bulkbar(page).locator('.bulkbar > button');
|
||||
|
||||
test('card checkboxes select images into the concept bar and Clear empties it', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// The concept's place: an empty bar between the filter row and the gallery layout.
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
expect(await bulkbar(page).evaluate(bar => [bar.previousElementSibling.className, bar.nextElementSibling.classList.contains('gallery-layout')]))
|
||||
.toEqual(['filter-row', true]);
|
||||
// Each overlay starts with the checkbox label, then the star.
|
||||
expect(await page.locator('.image-overlays').evaluateAll(rows => rows.map(row => [...row.children].map(child =>
|
||||
`${child.tagName.toLowerCase()}${child.querySelector(':scope > input') ? `>${child.firstElementChild.type}` : ''}`))))
|
||||
.toEqual([['label>checkbox', 'button'], ['label>checkbox', 'button']]);
|
||||
const [first, second] = [cards(page).first(), cards(page).last()];
|
||||
const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')));
|
||||
await checkbox(first, 'same.png').check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
await expect(barButtons(page)).toHaveText(['Set category', 'Export metadata', 'Clear']);
|
||||
// Toggling neither opens the viewer, nor inspects, nor moves focus.
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(checkbox(first, 'same.png')).toBeFocused();
|
||||
expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')))).toEqual(selectedBefore);
|
||||
// The count updates in place: the same bar and count nodes.
|
||||
await bulkbar(page).locator('.bulkbar > strong').evaluate(node => { node.dataset.kept = 'yes'; });
|
||||
await checkbox(second, 'same.png').check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveAttribute('data-kept', 'yes');
|
||||
// Set category opens its dialog; Cancel keeps the selection.
|
||||
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await page.getByRole('dialog', { name: 'Categorize selected images', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
await checkbox(second, 'same.png').uncheck();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
await checkbox(second, 'same.png').check();
|
||||
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
// The pressed control hid with its bar: focus moves to the page heading, not to the page.
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(checkbox(first, 'same.png')).not.toBeChecked();
|
||||
await expect(checkbox(second, 'same.png')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('the card checkbox keeps its own Space and Tab', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
const first = cards(page).first();
|
||||
await first.getByRole('button', { name: 'Open same.png', exact: true }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(checkbox(first, 'same.png')).toBeFocused();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(checkbox(first, 'same.png')).toBeChecked();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(star(first, 'same.png')).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(checkbox(first, 'same.png')).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(first.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
|
||||
// An arrow key on the checkbox still moves between cards, as from the card's other controls.
|
||||
await checkbox(first, 'same.png').focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(cards(page).last().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
|
||||
await expect(checkbox(first, 'same.png')).toBeChecked();
|
||||
});
|
||||
|
||||
test('the selection survives scrolling, re-mounting and appended batches, and a search clears it', async ({ page, request }) => {
|
||||
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(40);
|
||||
const firstKey = await cards(page).first().getAttribute('data-image-key');
|
||||
await cards(page).first().getByRole('checkbox', { name: /^Select / }).check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
const more = page.getByRole('button', { name: 'Load more images', exact: true });
|
||||
for (const loaded of [80, 120, 160, 200, 202]) {
|
||||
await more.click();
|
||||
await expect(caption(page)).toContainText(`${loaded} of 202 images`);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
}
|
||||
// A card from a later batch joins the kept selection.
|
||||
const late = page.locator('article[data-image-key][data-index="200"]');
|
||||
await late.getByRole('checkbox', { name: /^Select / }).check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
// Bounded rendering unmounted the first card; scrolled back, it is mounted again with its box checked.
|
||||
await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toHaveCount(0);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
const again = page.locator(`article[data-image-key="${firstKey}"]`);
|
||||
await expect(again).toBeVisible();
|
||||
await expect(again.getByRole('checkbox', { name: /^Select / })).toBeChecked();
|
||||
await expect(cards(page).nth(1).getByRole('checkbox', { name: /^Select / })).not.toBeChecked();
|
||||
// Returning to Images keeps the restored collection and its selection.
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(() => { window.location.hash = '/images'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
// A search replaces the collection, and with it the selection.
|
||||
await search(page).fill('0001');
|
||||
await expect(caption(page)).not.toContainText('202 images');
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await search(page).fill('');
|
||||
await expect(caption(page)).toContainText('of 202 images');
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(page.locator(`article[data-image-key="${firstKey}"]`).getByRole('checkbox', { name: /^Select / })).not.toBeChecked();
|
||||
// A sort change replaces the collection too.
|
||||
await cards(page).first().getByRole('checkbox', { name: /^Select / }).check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
await sortBy(page).selectOption('oldest');
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the card checkbox does not open the viewer', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
const first = cards(page).first();
|
||||
const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')));
|
||||
const inspectorBefore = await details(page).innerText();
|
||||
// A press on the label around the box toggles it, as does a press on the box itself.
|
||||
await first.locator('.image-overlays > label').click({ position: { x: 1, y: 1 } });
|
||||
await expect(checkbox(first, 'same.png')).toBeChecked();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
await checkbox(first, 'same.png').click();
|
||||
await expect(checkbox(first, 'same.png')).not.toBeChecked();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
|
||||
// Neither press selected the card or inspected it.
|
||||
expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')))).toEqual(selectedBefore);
|
||||
expect(await details(page).innerText()).toBe(inspectorBefore);
|
||||
});
|
||||
|
||||
// #249 D8 follow-up (image selection, Task 2): Export metadata downloads one bundle of the inspector's envelopes.
|
||||
const exportButton = page => bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true });
|
||||
const inspectRoute = '**/prompt_manager/images/inspect/**';
|
||||
const atRoot = (route, root) => new URL(route.request().url()).searchParams.get('root') === root;
|
||||
async function checkBoth(page) {
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await checkbox(cards(page).first(), 'same.png').check();
|
||||
await checkbox(cards(page).last(), 'same.png').check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
}
|
||||
async function bundleOf(download) {
|
||||
const fs = await import('node:fs/promises');
|
||||
return JSON.parse(await fs.readFile(await download.path(), 'utf8'));
|
||||
}
|
||||
|
||||
test('Export metadata downloads one bundle of the checked images in listing order and keeps the selection', async ({ page }) => {
|
||||
// Record the Blob URLs the page creates and revokes, to prove the download's URL is released.
|
||||
await page.addInitScript(() => {
|
||||
const blobs = window.__blobs = { created: [], revoked: [] };
|
||||
const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);
|
||||
URL.createObjectURL = value => { const url = create(value); blobs.created.push(url); return url; };
|
||||
URL.revokeObjectURL = url => { blobs.revoked.push(url); revoke(url); };
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const keys = await cards(page).evaluateAll(nodes => nodes.map(node => node.dataset.imageKey));
|
||||
// Hold both reads: the button is busy and a second press starts nothing more.
|
||||
const held = [], reads = [];
|
||||
await page.route(inspectRoute, route => { reads.push(route.request().url()); held.push(route); });
|
||||
const downloads = [];
|
||||
page.on('download', download => downloads.push(download));
|
||||
await exportButton(page).click();
|
||||
await expect.poll(() => held.length).toBe(2);
|
||||
await expect(exportButton(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
// Playwright waits on an aria-disabled control, so the second press is a dispatched click.
|
||||
await exportButton(page).dispatchEvent('click');
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
const saving = page.waitForEvent('download');
|
||||
for (const route of held) await route.continue();
|
||||
const download = await saving;
|
||||
expect(download.suggestedFilename()).toBe('prompt-manager-images-metadata.json');
|
||||
const bundle = await bundleOf(download);
|
||||
expect([bundle.format, bundle.version]).toEqual(['prompt-manager-images-metadata', 1]);
|
||||
expect(Number.isNaN(Date.parse(bundle.exported_at))).toBe(false);
|
||||
expect(bundle.images.map(image => image.identity.key)).toEqual(keys);
|
||||
expect(bundle.images.map(image => image.identity.path.split('/').at(-1))).toEqual(['same.png', 'same.png']);
|
||||
expect(bundle.images.every(image => image.inspection_version === 1 && image.metadata.prompt_graph === undefined)).toBe(true);
|
||||
await expect(toast(page)).toHaveText('Exported metadata for 2 images.');
|
||||
await expect(exportButton(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
expect(reads).toHaveLength(2);
|
||||
expect(downloads).toHaveLength(1);
|
||||
// The bundle's one Blob URL is revoked once the download started.
|
||||
await expect.poll(() => page.evaluate(() => window.__blobs)).toEqual({ created: [expect.stringMatching(/^blob:/)], revoked: [expect.stringMatching(/^blob:/)] });
|
||||
const blobs = await page.evaluate(() => window.__blobs);
|
||||
expect(blobs.revoked).toEqual(blobs.created);
|
||||
// The selection is kept after an export.
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('Export metadata reports images that could not be read and exports the rest', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
await page.route(inspectRoute, route => atRoot(route, '1') ?
|
||||
route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }) : route.continue());
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), exportButton(page).click()]);
|
||||
const bundle = await bundleOf(download);
|
||||
expect(bundle.images.map(image => image.identity.key)).toEqual(['0:shared/same.png']);
|
||||
await expect(toast(page)).toHaveText('Exported metadata for 1 of 2 images. 1 could not be read.');
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
});
|
||||
|
||||
test('Export metadata downloads nothing when no image can be read', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
await page.route(inspectRoute, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
|
||||
const downloads = [];
|
||||
page.on('download', download => downloads.push(download));
|
||||
await exportButton(page).click();
|
||||
await expect(toast(page)).toHaveText('Metadata could not be read. Try again.');
|
||||
await expect(exportButton(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
expect(downloads).toHaveLength(0);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
});
|
||||
|
||||
// #249 D8 follow-up (image selection, Task 3): Set category sets the linked positives' category (app.js simpleInput).
|
||||
const categoryButton = page => bulkbar(page).getByRole('button', { name: 'Set category', exact: true });
|
||||
const categoryDialog = page => page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
|
||||
const categoryRoute = '**/prompt_manager/bulk/category';
|
||||
/** Unlink the input root's card by deleting its prompt; returns the output card's positive id. */
|
||||
async function unlinkInputCard(request) {
|
||||
const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace')).json();
|
||||
const byKey = Object.fromEntries(images.map(image => [`${image.root_index}:${image.path}`, image]));
|
||||
const [output, input] = [byKey['0:shared/same.png'], byKey['1:shared/same.png']];
|
||||
expect([output.linked.status, input.linked.status]).toEqual(['linked', 'linked']);
|
||||
await expect(await request.delete(`/prompt_manager/delete/${input.linked.positive_id}`)).toBeOK();
|
||||
return output.linked.positive_id;
|
||||
}
|
||||
|
||||
test('Set category applies the category to the linked positives and the category filter then matches', async ({ page, request }) => {
|
||||
const outputPositive = await unlinkInputCard(request);
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const opener = categoryButton(page);
|
||||
await opener.click();
|
||||
const dialog = categoryDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
// The concept's simpleInput: one field and Cancel / Apply (primary).
|
||||
await expect(dialog.locator('#modal-body > .field > label[for="action-input"]')).toHaveText('Category');
|
||||
await expect(dialog.locator('#modal-body > .field > input#action-input')).toHaveValue('');
|
||||
await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Apply']);
|
||||
await expect(dialog.getByRole('button', { name: 'Apply', exact: true })).toHaveClass(/ca-primary/);
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toBeFocused();
|
||||
// An empty value applies nothing and keeps the dialog open.
|
||||
const posts = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/bulk/category')) posts.push(item.postDataJSON()); });
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Category', { exact: true }).fill(' Landscapes ');
|
||||
const listings = [];
|
||||
page.on('request', item => { if (imagePages(new URL(item.url()))) listings.push(item.url()); });
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(toast(page)).toHaveText('Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
|
||||
expect(posts).toEqual([{ prompt_ids: [outputPositive], category: 'Landscapes' }]);
|
||||
// The listing and the category options reload; the reload clears the selection.
|
||||
await expect.poll(() => listings.length).toBeGreaterThan(0);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
const filter = page.getByRole('combobox', { name: 'Filter by category', exact: true });
|
||||
await expect(filter.locator('option')).toContainText(['Landscapes']);
|
||||
await filter.selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
});
|
||||
|
||||
test('Set category without a linked image posts nothing and opens no dialog', async ({ page, request }) => {
|
||||
await unlinkInputCard(request);
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
let posted = 0;
|
||||
await page.route(categoryRoute, route => { posted += 1; return route.continue(); });
|
||||
await checkbox(cards(page).last(), 'same.png').check();
|
||||
await categoryButton(page).click();
|
||||
await expect(toast(page)).toHaveText('None of the selected images has a linked prompt.');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
expect(posted).toBe(0);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
});
|
||||
|
||||
test('Set category reports a failure, keeps the selection and ignores a second press while it runs', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const held = [];
|
||||
await page.route(categoryRoute, route => { held.push(route); });
|
||||
await categoryButton(page).click();
|
||||
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await expect(categoryButton(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
// Playwright waits on an aria-disabled control, so the second press is a dispatched click.
|
||||
await categoryButton(page).dispatchEvent('click');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await held[0].fulfill({ status: 500, json: { success: false, error: 'boom' } });
|
||||
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
|
||||
await expect(categoryButton(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
expect(held).toHaveLength(1);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('leaving Images during an export downloads nothing and shows no toast', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const held = [];
|
||||
await page.route(inspectRoute, route => { held.push(route); });
|
||||
const downloads = [];
|
||||
page.on('download', download => downloads.push(download));
|
||||
await exportButton(page).click();
|
||||
await expect.poll(() => held.length).toBe(2);
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// The reads were aborted with the page; answering them now changes nothing.
|
||||
for (const route of held) await route.continue().catch(() => {});
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
expect(downloads).toHaveLength(0);
|
||||
await expect(toast(page)).not.toContainText('etadata');
|
||||
});
|
||||
|
||||
test('leaving Images during Set category finishes the write, and the return reloads the collection', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const held = [];
|
||||
await page.route(categoryRoute, route => { held.push(route); });
|
||||
await categoryButton(page).click();
|
||||
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// The write is not aborted with the page: the server still receives and answers it.
|
||||
const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/bulk/category'));
|
||||
await held[0].continue();
|
||||
expect((await answered).ok()).toBe(true);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
await expect(toast(page)).not.toContainText('Category');
|
||||
// The saved visit predates the write, so the return loads the collection afresh: no stale selection or categories.
|
||||
await page.evaluate(() => { window.location.hash = '/images'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true }).locator('option')).toContainText(['Landscapes']);
|
||||
});
|
||||
|
||||
test('Set category limits the field to the server length, shows the server count, and a rejection keeps the selection', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const answers = [
|
||||
{ status: 400, json: { success: false, error: 'Category cannot exceed 100 characters' } },
|
||||
{ status: 200, json: { success: true, updated_count: 0 } },
|
||||
];
|
||||
await page.route(categoryRoute, route => route.fulfill(answers.shift()));
|
||||
for (const expected of ['Category could not be set. Try again.', 'Category set on 0 prompts.']) {
|
||||
await categoryButton(page).click();
|
||||
const field = categoryDialog(page).getByLabel('Category', { exact: true });
|
||||
await expect(field).toHaveAttribute('maxlength', '100');
|
||||
await field.fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(expected);
|
||||
if (answers.length) await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
}
|
||||
// The success reload clears the selection.
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
expect(answers).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -438,7 +438,7 @@ const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants
|
||||
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const toast=page=>page.locator('#workspace-toast');
|
||||
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
|
||||
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
|
||||
const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]');
|
||||
async function openHistory(page,id) {
|
||||
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
|
||||
}
|
||||
@@ -450,14 +450,14 @@ async function leaveAndReturn(page,id) {
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
|
||||
}
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
|
||||
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
|
||||
// One linked image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1);
|
||||
// One image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
@@ -472,7 +472,7 @@ test('library heading offers Export then + New prompt, and New opens the saved p
|
||||
// Exactly one saved negative is counted in the singular, in history and on the shelf card.
|
||||
await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('− 1 negative variant',{exact:true})).toHaveCount(1);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
|
||||
});
|
||||
|
||||
@@ -497,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
|
||||
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
|
||||
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
|
||||
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
|
||||
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
|
||||
@@ -509,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
|
||||
test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
// #249 D26: All images opens the concept dialog and leaves the selected negative as it was.
|
||||
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
||||
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
|
||||
await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await leaveAndReturn(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -531,8 +533,8 @@ test('add negative re-renders history with the saved or reused pair selected',as
|
||||
await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
|
||||
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
|
||||
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
@@ -550,9 +552,9 @@ test('reused pair outside first forty remains selected without loading every pag
|
||||
await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0;
|
||||
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();
|
||||
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
|
||||
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41);
|
||||
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -565,7 +567,7 @@ test('edit metadata stays on the page and re-reads the selected pair',async({pag
|
||||
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(toast(page)).toHaveText('Saved.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('article.surface > span.chip').first()).toHaveText('Studio work');
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
|
||||
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
|
||||
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
|
||||
@@ -639,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra
|
||||
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
|
||||
});
|
||||
|
||||
// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis.
|
||||
test('show archived and restore still work',async({page,request})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
const timeline=variantsPanel(page).locator(':scope > .timeline');
|
||||
expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
|
||||
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
|
||||
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
||||
const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1);
|
||||
await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/);
|
||||
const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−');
|
||||
expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1);
|
||||
expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1);
|
||||
await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1');
|
||||
await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
|
||||
const writes=page.writes.length;
|
||||
await lane.getByRole('button',{name:/^Restore /}).click();
|
||||
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible();
|
||||
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
|
||||
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
|
||||
await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
@@ -668,18 +693,21 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
|
||||
expect(await evidence()).toEqual(before);
|
||||
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
|
||||
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
||||
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
|
||||
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
|
||||
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
||||
const lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1);
|
||||
const opacity=await lane.locator('.variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
||||
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
|
||||
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
// #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar.
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
|
||||
await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
|
||||
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
|
||||
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
|
||||
@@ -687,7 +715,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
await lane.getByRole('button',{name:/^Restore/}).click();
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]);
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
@@ -695,14 +723,14 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
});
|
||||
|
||||
test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone');
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Standalone');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);});
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click();
|
||||
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone');
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Standalone');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible();
|
||||
expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true);
|
||||
@@ -725,7 +753,7 @@ test('export downloads the full Library JSON without a toast and reports a failu
|
||||
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
|
||||
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
|
||||
|
||||
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
@@ -736,8 +764,8 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
await expect(toast(page)).toHaveText(message);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
|
||||
await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative);
|
||||
await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative);
|
||||
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
|
||||
await uniqueDotNames(page);
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
@@ -745,7 +773,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText(message);
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -806,7 +834,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
|
||||
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
});
|
||||
|
||||
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
|
||||
@@ -870,7 +898,7 @@ test('long and error states retain footer focus and reflow',async({page})=>{
|
||||
|
||||
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
'js/workspace/library.css','js/workspace/workspace.css'];
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});const f=page.fixture;const references={};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
|
||||
@@ -7,13 +7,32 @@ const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
|
||||
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
|
||||
const media=page=>gallery(page).locator('article[data-image-key]');
|
||||
const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
|
||||
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
/** The Library's Tags filter lives in "Refine this view" (#249 D14). */
|
||||
async function applyTags(page,tags) {
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter is active.
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:/^Filters(?: · 1)?$/}).click();
|
||||
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill(tags);
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
}
|
||||
/** The first shelf page, then `pages` more, each waited for by its own response (the caption counts the filtered set). */
|
||||
async function loadShelfPages(page,more,pages) {
|
||||
await expect(page.locator('.section-caption')).toContainText('203 positive prompts');await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
for(let index=0;index<pages;index++) {
|
||||
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
||||
await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
}
|
||||
async function openHistory(page,text='Cat portrait') {
|
||||
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
|
||||
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
await expect(history(page)).toBeVisible();
|
||||
}
|
||||
// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position.
|
||||
const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot');
|
||||
async function selectVariant(page,index) {
|
||||
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
|
||||
const choice=variantDot(page,index);
|
||||
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
|
||||
}
|
||||
|
||||
@@ -23,7 +42,8 @@ test.beforeEach(async({page,request,browserName})=>{
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
|
||||
|
||||
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{
|
||||
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();
|
||||
await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1);
|
||||
@@ -31,7 +51,8 @@ test('library groups reusable negatives under one positive without exposing nega
|
||||
await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0);
|
||||
const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})});
|
||||
await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i);
|
||||
await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i);
|
||||
// D23: "N runs" is the parent's own run_count, never a recorded-execution count.
|
||||
for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
|
||||
});
|
||||
|
||||
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
|
||||
@@ -39,31 +60,34 @@ test('history displays the selected full negative beneath its positive and filte
|
||||
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
|
||||
await page.goto(route);await openHistory(page);
|
||||
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
|
||||
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
// #249 D26: All images for this positive opens a dialog; it is not a gallery filter.
|
||||
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/);
|
||||
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
|
||||
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
|
||||
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
|
||||
await expect(neg).toContainText(negative||'Empty negative prompt');
|
||||
await expect(neg.locator('p.prompt-text')).toHaveText(negative||'No negative prompt (explicitly empty)');
|
||||
await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
|
||||
await expect(positive).toContainText('Cat portrait');
|
||||
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
|
||||
expect(order&4).toBeTruthy();
|
||||
}
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
|
||||
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3);
|
||||
expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
|
||||
await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
|
||||
await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape');
|
||||
await page.goto(route);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Dog landscape');
|
||||
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');
|
||||
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('');
|
||||
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits');
|
||||
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0);
|
||||
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('');
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
|
||||
await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
await applyTags(page,'outdoors');await expect(cards(page)).toHaveCount(0);
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{
|
||||
@@ -83,10 +107,34 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
|
||||
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
|
||||
const firstId=await cards(page).first().getAttribute('data-prompt-id');
|
||||
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true});
|
||||
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('button',{name:'Explore history',exact:true});
|
||||
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
|
||||
});
|
||||
|
||||
// D40/D15: with no Refresh, Retry also re-reads a category list that failed with the shelf.
|
||||
test('Retry re-reads a failed category list with the shelf',async({page})=>{
|
||||
let fail=true;const reads=[];
|
||||
await page.route(url=>['/prompt_manager/library','/prompt_manager/library/options'].includes(url.pathname),async request=>{
|
||||
reads.push(new URL(request.request().url()).pathname);
|
||||
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
|
||||
await request.continue().catch(()=>{});
|
||||
});
|
||||
await page.goto(route);
|
||||
const retry=page.getByRole('button',{name:'Retry prompt library',exact:true});await expect(retry).toBeVisible();
|
||||
const categoriesFailed=page.getByRole('alert').filter({hasText:'Categories could not be loaded. Open the Prompt library again to retry.'});
|
||||
await expect(categoriesFailed).toBeVisible();
|
||||
fail=false;reads.length=0;await retry.click();await expect(cards(page)).toHaveCount(3);
|
||||
await expect(categoriesFailed).toBeHidden();
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true}).locator('option',{hasText:'Portraits'})).toHaveCount(1);
|
||||
expect(reads).toContain('/prompt_manager/library/options');
|
||||
// Apply with the same Tags, and a search that lands on the values already shown, reload nothing.
|
||||
reads.length=0;
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
|
||||
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
|
||||
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/library?*',async request=>{
|
||||
@@ -97,11 +145,82 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
});
|
||||
await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible();
|
||||
fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
|
||||
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
|
||||
});
|
||||
|
||||
test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{
|
||||
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Filters.
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
|
||||
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
|
||||
const sorted=page.waitForRequest(item=>new URL(item.url()).searchParams.get('sort')==='name');
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption({label:'Name A–Z'});await sorted;
|
||||
});
|
||||
|
||||
test('caption counts equal the filtered cards',async({page,request})=>{
|
||||
const totals=async query=>{const data=await(await request.get(`/prompt_manager/library?${query}`)).json();return [data.items.length,data.items.reduce((sum,item)=>sum+item.variant_count,0),data.variant_total];};
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const caption=page.locator('#workspace-main .section-caption');
|
||||
for(const [query,act] of [['',null],['category=Portraits',()=>page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits')],['category=Portraits&tag=outdoors',()=>applyTags(page,'outdoors')]]) {
|
||||
if(act)await act();
|
||||
const [count,variants,total]=await totals(query);expect(variants).toBe(total);
|
||||
await expect(cards(page)).toHaveCount(count);
|
||||
await expect(caption.locator('span')).toHaveText([`${count} positive prompt${count===1?'':'s'} · ${variants} negative variant${variants===1?'':'s'}`,'Images are grouped by positive prompt']);
|
||||
}
|
||||
});
|
||||
|
||||
// #249 final review M5: the count is a status region, written only when it changes, so Load more never re-announces it.
|
||||
test('the caption count is announced once per change and kept while the shelf reloads',async({page,request})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await page.goto(route);const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
const count=page.locator('#workspace-main .section-caption > span').first();
|
||||
await expect(count).toHaveAttribute('role','status');await expect(count).toHaveText(/^203 positive prompts · \d+ negative variants?$/);
|
||||
await count.evaluate(node=>{window.__countWrites=0;new MutationObserver(records=>{window.__countWrites+=records.length;}).observe(node,{childList:true,characterData:true,subtree:true});});
|
||||
await loadShelfPages(page,more,1);
|
||||
expect(await page.evaluate(()=>window.__countWrites)).toBe(0);
|
||||
// A filter change keeps the last count shown while the next page loads, then writes the new one.
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library'&&url.searchParams.get('category')==='Portraits',async route=>{await held;await route.continue();});
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
|
||||
await expect(page.locator('.library-status')).toHaveText('Loading prompt library…');
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();await expect(count).toHaveText(/^203 positive prompts/);
|
||||
release();await expect(count).not.toHaveText(/^203 /);
|
||||
expect(await page.evaluate(()=>window.__countWrites)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{
|
||||
await page.goto(route);await openHistory(page);
|
||||
// The unassigned file is reached through the All images dialog (#249 D26).
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3);
|
||||
const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
|
||||
const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true});
|
||||
await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0);
|
||||
await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0);
|
||||
const saved=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/images/favorite'&&item.request().method()==='PUT');
|
||||
await star.click();expect((await saved).ok()).toBe(true);
|
||||
await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★');
|
||||
await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible();
|
||||
await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
});
|
||||
|
||||
test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id');
|
||||
await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK();
|
||||
@@ -111,31 +230,32 @@ test('refresh atomically discards old row geometry and renders changed parent da
|
||||
window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);});
|
||||
});
|
||||
try {
|
||||
await page.getByRole('button',{name:'Refresh library',exact:true}).click();
|
||||
// D15: no Refresh; any filter change restarts the stream the same way.
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');
|
||||
await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…');
|
||||
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
|
||||
await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1);
|
||||
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
|
||||
});
|
||||
|
||||
test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals');
|
||||
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Animals');
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await openHistory(page);await selectVariant(page,0);
|
||||
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:300});await page.goto(route);
|
||||
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive');
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive');
|
||||
await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.');
|
||||
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
|
||||
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
|
||||
@@ -143,17 +263,19 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click());
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape');
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.focus();await search.fill('Dog landscape');
|
||||
await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused();
|
||||
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1);
|
||||
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');await expect(cards(page)).toHaveCount(1);
|
||||
expect(await page.evaluate(()=>scrollY)).toBe(0);
|
||||
const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
|
||||
await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
|
||||
await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
// A short phone window, so the one-card shelf can scroll far enough to put the parent back at its offset (nothing
|
||||
// follows the shelf's continuation since the legacy link moved to Overview, #249 E3).
|
||||
await page.setViewportSize({width:390,height:600});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape');
|
||||
});
|
||||
|
||||
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
|
||||
@@ -163,7 +285,7 @@ test('history detail failures retry and returning to the list ignores late detai
|
||||
await hold;await route.continue().catch(()=>{});
|
||||
});
|
||||
try {
|
||||
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
|
||||
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
|
||||
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release();
|
||||
@@ -187,7 +309,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id');
|
||||
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
|
||||
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
|
||||
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
|
||||
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
|
||||
await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
@@ -198,10 +320,12 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();
|
||||
await media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click();
|
||||
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
const inspector=page.getByRole('complementary',{name:'Selected image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
await expect(inspector.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
// D20: provenance and raw records are the viewer's full details, opened from the card image (D19).
|
||||
await expect(inspector.locator('summary')).toHaveCount(0);
|
||||
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await expect(page.locator('dialog[open] #viewer-details summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
|
||||
@@ -210,11 +334,11 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
|
||||
await page.goto(route);await openHistory(page);
|
||||
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
|
||||
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
|
||||
const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40);
|
||||
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
|
||||
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
|
||||
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative);
|
||||
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative);
|
||||
});
|
||||
|
||||
async function observeLibrary(page, hold=false) {
|
||||
@@ -231,7 +355,7 @@ async function observeLibrary(page, hold=false) {
|
||||
record.native=new NativeResizeObserver(entries=>{
|
||||
const ready=[];
|
||||
for(const entry of entries) {
|
||||
if(entry.target.matches('.library-card')) {
|
||||
if(entry.target.matches('.prompt-card')) {
|
||||
if(!entry.target.isConnected)unmounted++;
|
||||
record.latest.set(entry.target,entry);
|
||||
if(holding) {record.entries.set(entry.target,entry);continue;}
|
||||
@@ -245,7 +369,7 @@ async function observeLibrary(page, hold=false) {
|
||||
unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);}
|
||||
disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();}
|
||||
};
|
||||
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
|
||||
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.prompt-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
|
||||
window.libraryObservation={
|
||||
hold(){holding=true;},
|
||||
release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}},
|
||||
@@ -273,37 +397,40 @@ async function retainedParent(page, anchor) {
|
||||
await expect.poll(async()=>Math.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
test('Library natural rows retain long prompt content without overflow',async({page,request})=>{
|
||||
test('Library cards clamp long prompts and keep real content inside the card',async({page,request})=>{
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
const data=await(await request.get('/prompt_manager/library')).json();
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6);
|
||||
expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toContainText('One positive.');
|
||||
await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
|
||||
await expect(page.locator('.prompt-section.positive .role-label').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
|
||||
}
|
||||
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
|
||||
await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
|
||||
await expect(page.locator('.prompt-card .card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
|
||||
await expect(page.locator('.card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
|
||||
await expect(page.locator('.library-grid')).toHaveCSS('gap','18px');
|
||||
const long=cards(page).filter({hasText:'A study in very long creative context'});
|
||||
const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500);
|
||||
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
|
||||
for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) {
|
||||
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true);
|
||||
// D23: two lines of the concept's 12px/1.75 prompt text, the rest clipped inside the card.
|
||||
expect(await prompt.evaluate(node=>({height:node.clientHeight,clipped:node.scrollHeight>node.clientHeight+1}))).toEqual({height:42,clipped:true});
|
||||
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeLessThan(560);
|
||||
for(const selector of ['.card-title','.card-content','.card-content > .tags','.card-footer']) {
|
||||
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1&&node.scrollWidth<=node.clientWidth+1),selector).toBe(true);
|
||||
}
|
||||
await prompt.focus();await expect(prompt).toBeFocused();
|
||||
await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
|
||||
await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible();
|
||||
await long.getByRole('button',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
|
||||
await expect(long.getByRole('button',{name:'Explore history',exact:true})).toBeVisible();
|
||||
for(const item of data.items) {
|
||||
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
|
||||
await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1);
|
||||
await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags);
|
||||
const footer=card.locator('.card-footer > span');
|
||||
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
|
||||
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
|
||||
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
|
||||
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
|
||||
}
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i);
|
||||
const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};}));
|
||||
for(let i=0;i<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);}
|
||||
}
|
||||
@@ -317,23 +444,22 @@ test('row measurement preserves anchor after width invalidation and append',asyn
|
||||
try {
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
|
||||
await expect(page.locator('.library-caption')).toContainText('160 of 203');
|
||||
await loadShelfPages(page,more,3);
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25));
|
||||
await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
|
||||
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor);
|
||||
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
|
||||
await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.prompt-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
|
||||
{const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');}await settleLibrary(page);await retainedParent(page,after);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
expect(await cards(page).count()).toBeLessThan(80);
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
|
||||
for(const changedWidth of [false,true]) {
|
||||
const before=await firstVisibleParent(page);
|
||||
await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
await page.locator(`article[data-prompt-id="${before.id}"]`).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
|
||||
expect(await cards(page).count()).toBeLessThan(80);
|
||||
@@ -347,11 +473,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
|
||||
await expect(page.locator('.library-caption')).toContainText('160 of 203');
|
||||
await loadShelfPages(page,more,3);
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25));
|
||||
await settleLibrary(page);await page.mouse.move(1100,550);
|
||||
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
try {
|
||||
// The same queued-frame wheel sequence is stable without changed measurements.
|
||||
const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY);
|
||||
@@ -362,7 +487,7 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160});
|
||||
|
||||
const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';});
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='45px';});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
|
||||
const parent=page.locator(`article[data-prompt-id="${before.id}"]`);
|
||||
const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset;
|
||||
@@ -378,10 +503,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
|
||||
// The correction's own native scroll event must not become new user motion.
|
||||
const corrected=await firstVisibleParent(page);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';});
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='65px';});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
|
||||
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length);
|
||||
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
|
||||
@@ -411,8 +536,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId;
|
||||
} else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);}
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
|
||||
await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page);
|
||||
await loadShelfPages(page,more,3);await settleLibrary(page);
|
||||
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0);
|
||||
const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
|
||||
await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page);
|
||||
@@ -422,7 +546,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls);
|
||||
const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page);
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
|
||||
const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);
|
||||
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1);
|
||||
await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page);
|
||||
@@ -436,12 +560,151 @@ test('saved choices retain curved staggered lanes and the full negative immediat
|
||||
await page.goto(route);await openHistory(page);await selectVariant(page,1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
|
||||
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
|
||||
const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
|
||||
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
|
||||
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
|
||||
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
const selected=details.variants[1].negative_text;
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').evaluate(node=>node.textContent)).toBe(selected);
|
||||
});
|
||||
|
||||
/** Rewrites each shelf item the server sends: run counts, last use and long texts are values the fixture never records. */
|
||||
async function patchShelf(page,patch) {
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library',async route=>{
|
||||
const response=await route.fetch();const body=await response.json();
|
||||
body.items=body.items.map(item=>({...item,...patch(item)}));
|
||||
await route.fulfill({response,json:body});
|
||||
});
|
||||
}
|
||||
/** The footer line as seen: everything but its visually hidden rating sentence. */
|
||||
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
|
||||
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
|
||||
|
||||
test('card matches the concept and never wraps the category mid-word',async({page,request})=>{
|
||||
const before=await(await request.get('/prompt_manager/library')).json();
|
||||
const dog=before.items.find(item=>item.text==='Dog landscape'),cat=before.items.find(item=>item.text==='Cat portrait');
|
||||
const dual=before.items.find(item=>item.text==='Dual role portrait');
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dog.id],category:'asian'}})).toBeOK();
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
|
||||
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
|
||||
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
|
||||
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
|
||||
await expect(card).toHaveClass('prompt-card');
|
||||
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
|
||||
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
|
||||
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
|
||||
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
|
||||
await expect(card.locator('.card-title > button')).toHaveText('Cat portrait');await expect(card.locator('.card-title > .chip')).toHaveText('Animals');
|
||||
await expect(card.locator('.card-title > button')).toHaveCSS('font-size','16px');await expect(card.locator('.card-title > button')).toHaveCSS('font-weight','600');
|
||||
const positive=card.locator('.card-content > .prompt-inspector > section.prompt-section.positive');await expect(positive).toHaveCount(1);
|
||||
await expect(card.locator('section.prompt-section.negative')).toHaveCount(0);
|
||||
await expect(positive.getByRole('button',{name:'Copy positive prompt: Cat portrait',exact:true})).toHaveCount(1);
|
||||
await expect(positive.getByRole('button',{name:'Send positive prompt to ComfyUI: Cat portrait',exact:true})).toHaveCount(1);
|
||||
await expect(card.locator('.card-meta > *')).toHaveText(['− 4 negative variants','4 runs','3 images']);
|
||||
await expect(card.locator('.card-meta > .chip.red')).toHaveCount(1);
|
||||
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
|
||||
const footer=card.locator('.card-footer');
|
||||
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
|
||||
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
|
||||
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
|
||||
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
|
||||
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
|
||||
// Unrated and never used: the used label alone; no negatives still reads as a count.
|
||||
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
|
||||
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
|
||||
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
|
||||
await expect(unrated.locator('.card-meta > *')).toHaveText([`− ${dual.variant_count} negative variant${dual.variant_count===1?'':'s'}`,'1 run',`${dual.image_count} image${dual.image_count===1?'':'s'}`]);
|
||||
for(const [id,text] of [[dual.id,'Uncategorized'],[dog.id,'asian']]) {
|
||||
const chip=page.locator(`article[data-prompt-id="${id}"] .card-title > .chip`);await expect(chip).toHaveText(text);
|
||||
expect(await chip.evaluate(node=>{const range=document.createRange();range.selectNodeContents(node);return range.getClientRects().length;}),`${text} at ${width}`).toBe(1);
|
||||
expect(await page.locator(`article[data-prompt-id="${id}"] .card-title`).evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
}
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('long prompt is clamped to two lines while copy sends the full text',async({page,context,request})=>{
|
||||
const before=await(await request.get('/prompt_manager/library')).json();const cat=before.items.find(item=>item.text==='Cat portrait');
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
await patchShelf(page,item=>item.id===cat.id?{text:longPrompt,title:'Long observatory'}:{});
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target={id:'positive-1',label:'Card destination',type:'PromptManager',roles:['positive']};
|
||||
window.cardApplied=[];
|
||||
window.cardHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Card ComfyUI',adapter:{
|
||||
listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive}},snapshot:roles}),
|
||||
apply:roles=>{window.cardApplied.push(roles);return{targetId:target.id,values:roles};},
|
||||
}});
|
||||
});
|
||||
try {
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`),text=card.locator('.prompt-text');
|
||||
await expect(text).toHaveText(longPrompt);
|
||||
const box=await text.evaluate(node=>{const style=getComputedStyle(node);return {lines:Math.round(node.clientHeight/parseFloat(style.lineHeight)),clipped:node.scrollHeight>node.clientHeight+1,clamp:style.webkitLineClamp,overflow:style.overflowY};});
|
||||
expect(box).toEqual({lines:2,clipped:true,clamp:'2',overflow:'hidden'});
|
||||
}
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
await card.getByRole('button',{name:'Copy positive prompt: Long observatory',exact:true}).click();
|
||||
await expect.poll(()=>page.evaluate(()=>window.copied)).toEqual([longPrompt]);
|
||||
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('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}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();
|
||||
} finally {await host.evaluate(()=>window.cardHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('title and Explore history are buttons that open history',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
await page.goto(route);const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
await expect(card.locator('a')).toHaveCount(0);
|
||||
const title=card.locator('.card-title > button');await expect(title).toHaveAttribute('type','button');
|
||||
await title.click();await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
|
||||
const explore=page.locator(`article[data-prompt-id="${cat.id}"]`).getByRole('button',{name:'Explore history',exact:true});
|
||||
await explore.focus();await page.keyboard.press('Enter');await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
// The card's navigation buttons keep the shelf's Home/End and arrow movement between cards.
|
||||
const first=cards(page).first();await first.locator('.card-title > button').focus();await page.keyboard.press('End');
|
||||
await expect(cards(page).last().locator('.card-title > button')).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused();
|
||||
});
|
||||
|
||||
test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await patchShelf(page,item=>({text:`${item.text}\n${longPrompt}`}));
|
||||
// Two columns on a desktop window; the touch project's phone gives the one-column shelf.
|
||||
{
|
||||
if(!isMobile)await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
await loadShelfPages(page,more,3);
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+3000));await settleLibrary(page);
|
||||
const geometry=await page.evaluate(()=>[...document.querySelector('.library-grid').children].map(node=>{
|
||||
const box=node.getBoundingClientRect();
|
||||
return {row:node.classList.contains('library-row'),top:box.top,height:box.height,
|
||||
cards:[...node.querySelectorAll('.prompt-card')].map(card=>({height:card.getBoundingClientRect().height,text:card.querySelector('.prompt-text').clientHeight,clipped:card.querySelector('.prompt-text').scrollHeight>card.querySelector('.prompt-text').clientHeight+1}))};
|
||||
}));
|
||||
const rows=geometry.filter(item=>item.row);expect(rows.length).toBeGreaterThan(2);expect(rows.length).toBeLessThan(60);
|
||||
for(const row of rows) {
|
||||
expect(row.cards.length).toBeGreaterThan(0);
|
||||
for(const card of row.cards) {expect(card.text).toBe(42);expect(card.clipped).toBe(true);expect(Math.abs(card.height-row.height)).toBeLessThanOrEqual(1);}
|
||||
}
|
||||
// Consecutive mounted rows sit one concept gap apart: every height the layout used is the measured one.
|
||||
for(let index=1;index<geometry.length;index++) if(geometry[index].row&&geometry[index-1].row) expect(Math.abs(geometry[index].top-(geometry[index-1].top+geometry[index-1].height+18))).toBeLessThanOrEqual(1);
|
||||
const anchor=await firstVisibleParent(page);await page.evaluate(()=>window.libraryObservation.equal());await settleLibrary(page);await retainedParent(page,anchor);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) {
|
||||
const screenshotPath = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage });
|
||||
await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' });
|
||||
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.detail-panel', '.prompt-heading', '.library-grid', '.prompt-card', '.prompt-card .card-art', '.prompt-card .card-content'].map(selector => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [selector, null];
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
@@ -94,23 +94,24 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
|
||||
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
|
||||
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
|
||||
const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title;
|
||||
const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, exact: true });
|
||||
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
|
||||
const actual = await historyGeometry(page, true);
|
||||
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
|
||||
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
const title = await page.locator('#workspace-main > .heading h1').textContent();
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name: `${name}: ${title}`, exact: true })).toBeVisible();
|
||||
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
|
||||
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-shared-gallery .image-card-actions button').first().click();
|
||||
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.getByRole('button', { name: 'Open full preview', exact: true }).click();
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.locator('.library-shared-gallery .media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
|
||||
for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
|
||||
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
|
||||
@@ -125,11 +126,15 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
|
||||
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible();
|
||||
await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).toBeVisible();
|
||||
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
|
||||
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
|
||||
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
|
||||
await currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click();
|
||||
// The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused.
|
||||
const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true });
|
||||
await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1);
|
||||
if (await copyFallback.isVisible()) { await page.keyboard.press('Escape'); await expect(copyFallback).toBeHidden(); }
|
||||
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog#viewer-dialog');
|
||||
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
|
||||
@@ -242,9 +247,10 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
await text.scrollIntoViewIfNeeded();
|
||||
expect((await text.textContent()).length).toBeGreaterThan(1500);
|
||||
await text.focus(); await expect(text).toBeFocused();
|
||||
// #249 D23: the card clamps the prompt to two lines; the card content itself never scrolls or overflows.
|
||||
expect(await text.evaluate(node => {
|
||||
const scroller = node.closest('.library-card-prompts');
|
||||
return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1;
|
||||
const content = node.closest('.card-content');
|
||||
return content.scrollHeight <= content.clientHeight + 1 && content.scrollWidth <= content.clientWidth + 1 && node.scrollHeight > node.clientHeight + 1;
|
||||
})).toBe(true);
|
||||
await noOverflow(page);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = ''; });
|
||||
@@ -346,19 +352,25 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
expect(filename).toMatch(/layered-context-.*\.png/);
|
||||
// D19: Info selects into the panel; the image itself opens the viewer.
|
||||
await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.image-details h2')).toHaveText(filename);
|
||||
const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true });
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
// D19: the card image, not the panel, opens the viewer.
|
||||
const fullPreview = longCard.locator('.image-open');
|
||||
await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await expect(page.locator('#viewer-title')).toHaveText(filename);
|
||||
await page.keyboard.press('Escape');
|
||||
// The concept's card order: image, the D7 star (hidden in List, as the concept hides its overlays), then Inspect.
|
||||
// The concept's card order (app.js mediaCard): image, the D8 checkbox, the D7 star, then Inspect. List hides
|
||||
// the overlays as the concept does (`.list-grid .image-overlays`), so there the image is followed by Inspect.
|
||||
const box = longCard.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
|
||||
const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true });
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
|
||||
if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Tab'); }
|
||||
const overlays = layout === 'List' ? [] : [box, star];
|
||||
if (layout === 'List') { await expect(box).toBeHidden(); await expect(star).toBeHidden(); }
|
||||
await longCard.locator('.image-open').focus();
|
||||
for (const control of overlays) { await page.keyboard.press('Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused();
|
||||
for (const control of [...overlays].reverse()) { await page.keyboard.press('Shift+Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Shift+Tab'); }
|
||||
await expect(longCard.locator('.image-open')).toBeFocused();
|
||||
await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
@@ -448,7 +460,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2);
|
||||
await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/);
|
||||
await expect(selected.locator('.image-open')).toBeFocused();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(filename);
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
const mutations = await grid.evaluate(async node => {
|
||||
let changes = 0;
|
||||
const observer = new MutationObserver(records => { changes += records.length; });
|
||||
@@ -528,9 +540,9 @@ test('Library visual evidence keeps natural cards and returns from real history
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false);
|
||||
await noOverflow(page);
|
||||
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
|
||||
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
|
||||
@@ -597,7 +609,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
for (const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.media-card .image-card-actions button').first().click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
|
||||
if(layout==='List') await expect(page.locator('.media-card').first()).toHaveCSS('height','97px');
|
||||
@@ -609,33 +621,36 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
expect(longName).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-card-actions button').click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(longName);
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
|
||||
}
|
||||
}
|
||||
await page.evaluate(()=>{location.hash='#/overview';});
|
||||
await expect(page.locator('.media-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('.media-card,.detail-panel,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
for (const width of [1600,390]) {
|
||||
await page.setViewportSize({width,height:width===1600?1100:844});await page.goto(`${workspace}#/library`);
|
||||
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
|
||||
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('h1')).not.toHaveText('Prompt library');
|
||||
const title=await page.locator('h1').textContent();expect(title).not.toBe('Images');
|
||||
// History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery.
|
||||
// History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked
|
||||
// to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26).
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await expect(listing.locator('article[data-image-key]').first()).toBeVisible();
|
||||
for (const layout of ['List','Masonry','Grid']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await expect(page.locator('h1')).toHaveText(title);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('.media-card,.image-details,dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,13 +24,18 @@ async function expectLibrary(page,{tags='',category='',count}) {
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||||
const select=page.getByRole('combobox',{name:'Category',exact:true});
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('');
|
||||
const select=page.getByRole('combobox',{name:'Filter by category',exact:true});
|
||||
await expect(select).toHaveValue(category);
|
||||
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
|
||||
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
|
||||
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(new RegExp(`^${count} positive prompts? · \\d+ negative variants?$`));
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
|
||||
// Browse sets exactly this Tags filter, which lives in "Refine this view" (#249 D14); Filters counts it (D44).
|
||||
const filters=page.locator('#workspace-main search.toolbar').getByRole('button',{name:tags?'Filters · 1':'Filters',exact:true});
|
||||
await expect(filters).toBeVisible();await filters.click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await expect(refine.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||||
await refine.getByRole('button',{name:'Close dialog',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
}
|
||||
|
||||
let manifest;
|
||||
@@ -85,9 +90,31 @@ test('prompt counts equal Library Browse results',async({page})=>{
|
||||
}
|
||||
});
|
||||
|
||||
// #249 D44: a Browse-set Tags filter is visible on the shelf as "Filters · 1", and Clear filters returns the full shelf.
|
||||
test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await page.goto(route);
|
||||
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{tags:'cinematic',count:2});
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
|
||||
await expect(shelf).toHaveCount(total);
|
||||
// The cleared filter stays cleared on the next in-app visit (the shelf remembers its filters).
|
||||
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
|
||||
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
});
|
||||
|
||||
test('category Browse sets only the category constraint',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});
|
||||
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click();
|
||||
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
|
||||
@@ -401,8 +428,8 @@ test('delete tag confirms real impact and removes only the vocabulary name',asyn
|
||||
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||||
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||||
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('cancel and escape write nothing and restore focus',async({page,request})=>{
|
||||
@@ -486,7 +513,7 @@ test('lost response is outcome-unknown',async({page,request})=>{
|
||||
|
||||
// Concept nav links end in their real `.nav-count` once it loads.
|
||||
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] :is(.card-title, .tags) > .chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
|
||||
async function heldRename(page) {
|
||||
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
|
||||
@@ -798,7 +825,7 @@ test('mutation check: the comparison reports a changed surface padding and a cha
|
||||
// Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its
|
||||
// dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
'js/workspace/library.css','js/workspace/workspace.css'];
|
||||
const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')));
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
|
||||
@@ -13,7 +13,8 @@ const routes = [
|
||||
['overview', 'Overview', null],
|
||||
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
|
||||
['images', 'Images', null],
|
||||
['library', 'Prompt library', '/prompt_manager/admin'],
|
||||
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
|
||||
['library', 'Prompt library', null],
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
@@ -81,8 +82,11 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.reload();
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
// D29: the legacy image gallery is reached from Overview's tool card, not from Images.
|
||||
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
|
||||
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();
|
||||
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();
|
||||
|
||||
@@ -354,7 +354,9 @@ def seed_workspace_concept(
|
||||
db = api.db
|
||||
epoch = db.get_execution_epoch()
|
||||
prompts = []
|
||||
for prompt in CONCEPT_PROMPTS:
|
||||
# The concept lists its prompts most recent first (renderLibrary under Recently
|
||||
# added keeps the array order), so they are created last to first.
|
||||
for prompt in reversed(CONCEPT_PROMPTS):
|
||||
first_name, first_text = prompt.negatives[0]
|
||||
receipt = db.create_library_recipe(
|
||||
prompt.positive,
|
||||
@@ -374,8 +376,8 @@ def seed_workspace_concept(
|
||||
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
|
||||
# Real queue-hook counting (D23): four runs, the last one now.
|
||||
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
|
||||
prompts.append(
|
||||
{"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
prompts.insert(
|
||||
0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
)
|
||||
|
||||
folder = root / "output" / "concepts"
|
||||
|
||||
@@ -479,8 +479,14 @@ def seed_workspace_history(
|
||||
canonical_positive, canonical_negative, title="Quality baseline"
|
||||
)
|
||||
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
|
||||
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
|
||||
clean_pair = api.db.save_prompt_pair(
|
||||
canonical_positive, "flat light", title="Clean geometry"
|
||||
)
|
||||
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
|
||||
# Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only
|
||||
# the baseline has a recorded execution, so Clean geometry opens Repeat run.
|
||||
api.db.record_prompt_pair_use(canonical_pair, 2)
|
||||
api.db.record_prompt_pair_use(clean_pair, 2)
|
||||
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
|
||||
path = folder / name
|
||||
_write_art(path, number, canonical_positive, canonical_negative)
|
||||
@@ -500,6 +506,8 @@ def seed_workspace_history(
|
||||
os.utime(path, (stamp, stamp))
|
||||
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
|
||||
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
|
||||
if number:
|
||||
api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"})
|
||||
canonical_run = run(
|
||||
(
|
||||
RecipeContext(
|
||||
@@ -529,6 +537,7 @@ def seed_workspace_history(
|
||||
"detail_image_id": current,
|
||||
"canonical_id": canonical_id,
|
||||
"canonical_pair": canonical_pair,
|
||||
"clean_pair": clean_pair,
|
||||
"canonical_run": canonical_run,
|
||||
"canonical_positive": canonical_positive,
|
||||
"canonical_negative": canonical_negative,
|
||||
|
||||
@@ -243,7 +243,7 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
const frozen = attempts[0].context;
|
||||
for (const mode of ['Raw', 'Effective']) {
|
||||
await run.getByRole('button', { name: mode, exact: true }).click();
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('p.prompt-text').evaluate(node => node.textContent)).toBe(value);
|
||||
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
|
||||
try {
|
||||
await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
|
||||
@@ -22,10 +22,10 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/library');
|
||||
await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive);
|
||||
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
|
||||
await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive);
|
||||
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
|
||||
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
await history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click();
|
||||
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
|
||||
// The pair send lives in the history heading, as in the approved concept.
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
@@ -67,7 +67,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1));
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText(title);
|
||||
const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('No negative prompt (explicitly empty)');
|
||||
const evidence=async()=>{
|
||||
const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json();
|
||||
const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json();
|
||||
|
||||
@@ -46,12 +46,12 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',exact:true}).click();
|
||||
const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
|
||||
await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
|
||||
const inspector=workspace.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
|
||||
const viewer=workspace.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
|
||||
await expect(viewer.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
|
||||
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(viewer.locator('pre code').first()).toContainText('9007199254740993');
|
||||
await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
@@ -59,12 +59,12 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
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);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
await expect(viewer).toBeVisible();await expect(viewer.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
}
|
||||
expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0);
|
||||
await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};});
|
||||
await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
await viewer.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).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);
|
||||
|
||||
+135
-15
@@ -5,7 +5,7 @@ import * as module from '../../web/js/workspace/prompt-inspector.mjs';
|
||||
|
||||
function dom() {
|
||||
class Element {
|
||||
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};}
|
||||
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};this.className='';}
|
||||
append(...children){for(const child of children){child.parent=this;this.children.push(child);}}
|
||||
replaceChildren(...children){this.children=[];this.append(...children);}
|
||||
setAttribute(key,value){this.attributes[key]=value;}
|
||||
@@ -13,9 +13,9 @@ 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;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
|
||||
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 walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
|
||||
return {Element,walk,restore:()=>{global.document=previous;}};
|
||||
return {Element,walk,toast,restore:()=>{global.document=previous;}};
|
||||
}
|
||||
const record=text=>({status:'resolved',text,source:'library'});
|
||||
test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{
|
||||
@@ -27,7 +27,7 @@ test('shared prompt blocks send complete selected library values and explicit em
|
||||
view.update({positive:record('Complete positive'),negative:record('')});
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,0,'no per-block source line');
|
||||
view.dispose();container.click(pair);assert.equal(sent.length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
@@ -51,19 +51,19 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
|
||||
test('frozen fields keep null, empty and whitespace actions distinct without a source line',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
|
||||
assert.equal(f.walk(container,node=>node.textContent?.includes('runtime_return')).length,0,'no per-block source line');
|
||||
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
|
||||
assert.equal(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length,0);
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent===' \n\t ').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
|
||||
assert.deepEqual(sent[1],{negative:' \n\t '});
|
||||
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
|
||||
@@ -71,19 +71,19 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
|
||||
test('a zeroed conditioning negative shows as an explicit empty negative without a source line',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,0);
|
||||
assert.equal(f.walk(container,node=>node.className==='prompt-text'&&node.textContent==='No negative prompt (explicitly empty)').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent);
|
||||
test('unsupported node status names the class',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
|
||||
@@ -101,7 +101,7 @@ test('runtime and composite statuses',()=>{
|
||||
assert.deepEqual(statusLines(f,container),[runtime,runtime]);
|
||||
view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
|
||||
assert.deepEqual(statusLines(f,container),[composite]);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent==='kept').length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('limit status',()=>{
|
||||
@@ -136,10 +136,130 @@ test('a context label names nested prompt regions and actions apart from the fro
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
|
||||
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
|
||||
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt to ComfyUI','Copy current file negative prompt','Send current file negative prompt to ComfyUI']) assert.ok(labels.includes(name),name);
|
||||
assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
|
||||
assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
|
||||
assert.deepEqual(f.walk(container,node=>node.className==='role-label').map(node=>node.textContent),['+ Positive prompt','\u2212 Negative prompt']);
|
||||
const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
|
||||
assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a title names each role action after its prompt as the concept does',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',prompts:{positive:record('p'),negative:record('n')}});
|
||||
const actions=f.walk(container,node=>node.tagName==='button').map(node=>node.attributes['aria-label']);
|
||||
assert.deepEqual(actions,['Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory',
|
||||
'Copy negative prompt: Alpine observatory','Send negative prompt to ComfyUI: Alpine observatory','Send prompt pair']);
|
||||
const nested=new f.Element('div');
|
||||
module.mountPromptBlocks(nested,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',title:'Alpine observatory',includePairAction:false,prompts:{positive:record('p')}});
|
||||
assert.deepEqual(f.walk(nested,node=>node.tagName==='button').map(node=>node.attributes['aria-label']),
|
||||
['Copy current file positive prompt: Alpine observatory','Send current file positive prompt to ComfyUI: Alpine observatory']);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a title names each action after its prompt (concept "Copy positive prompt: <title>") while regions keep their names',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',roles:['positive'],prompts:{positive:record('p')}});
|
||||
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
assert.deepEqual(labels,['Positive prompt','Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory']);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('setTitle renames the actions in place and keeps the last prompts and state',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Old name',roles:['positive'],prompts:{positive:record('p')}});
|
||||
view.setState({prompts:{positive:record('kept')},actionsEnabled:false});
|
||||
const labels=()=>f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
const rendered=container.children[0];view.setTitle('Old name');assert.equal(container.children[0],rendered,'an unchanged title does not re-render');
|
||||
view.setTitle('New name');
|
||||
assert.deepEqual(labels(),['Positive prompt','Copy positive prompt: New name','Send positive prompt to ComfyUI: New name']);
|
||||
assert.ok(f.walk(container,node=>node.textContent==='kept').length);
|
||||
assert.ok(f.walk(container,node=>node.dataset.copyRole==='positive').every(node=>node.disabled===true));
|
||||
view.setTitle('');assert.deepEqual(labels(),['Positive prompt','Copy positive prompt','Send positive prompt to ComfyUI']);
|
||||
scope.dispose();view.setTitle('After dispose');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}),
|
||||
...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})});
|
||||
test('prompt block emits concept markup without a source line',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,includePairAction:false,sender:{open:()=>{}},prompts:{positive:{...record('a lighthouse'),evidence:{provenance:'recorded',source:'runtime_return'}},negative:record('blurry')}});
|
||||
const section=(role,label,text)=>({tag:'section',className:`prompt-section ${role}`,label:`${role[0].toUpperCase()}${role.slice(1)} prompt`,children:[
|
||||
{tag:'div',className:'prompt-heading',children:[{tag:'span',className:'role-label',text:label},{tag:'div',className:'prompt-actions',children:[
|
||||
{tag:'button',className:'icon-btn',label:`Copy ${role} prompt`,children:[]},{tag:'button',className:'send icon-btn',label:`Send ${role} prompt to ComfyUI`,children:[]}]}]},
|
||||
{tag:'p',className:'prompt-text',text}]});
|
||||
assert.deepEqual(container.children.map(shape),[section('positive','+ Positive prompt','a lighthouse'),section('negative','\u2212 Negative prompt','blurry')]);
|
||||
for(const button of f.walk(container,node=>node.tagName==='button')) assert.equal(button.children.filter(child=>child.tagName==='svg').length,1,'one icon per action');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('empty negative uses the concept copy and still sends an explicit clear',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('positive'),negative:record('')}});
|
||||
const negative=container.children[1];
|
||||
assert.equal(negative.children[1].className,'prompt-text');assert.equal(negative.children[1].textContent,'No negative prompt (explicitly empty)');
|
||||
container.click(f.walk(negative,node=>node.dataset.sendRole==='negative')[0]);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{negative:''},{positive:'positive',negative:''}]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('unknown roles never offer copy or send',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,roles:['positive','negative','style'],sender:{open:roles=>sent.push(roles)},
|
||||
prompts:{positive:{status:'absent'},negative:{status:'ambiguous',text:'candidate'},style:record('not a role')}});
|
||||
assert.deepEqual(container.children.map(node=>node.className),['prompt-section positive','prompt-section negative'],'an unknown role name renders nothing');
|
||||
assert.deepEqual(statusLines(f,container),['Not recorded in this file.','Multiple possible prompts. Source could not be determined.']);
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined||node.className==='prompt-actions').length,0);
|
||||
const forged=new f.Element('button');forged.dataset.sendRole='style';container.append(forged);container.click(forged);
|
||||
assert.equal(sent.length,0,'a forged action for an unknown role sends nothing');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('copy shows the shared toast "Prompt copied." with the exact role text',async()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),copied=[];
|
||||
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
|
||||
Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async text=>{copied.push(text);}}}});
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record(' exact\ntext '),negative:record('')}});
|
||||
await container.listeners.get('click')({target:f.walk(container,node=>node.dataset.copyRole==='positive')[0]});
|
||||
assert.deepEqual(copied,[' exact\ntext ']);assert.equal(f.toast.textContent,'Prompt copied.');
|
||||
assert.equal(f.walk(container,node=>node.attributes.role==='status').length,0,'no per-block copy status line');
|
||||
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
|
||||
});
|
||||
|
||||
/** Runs one copy click with `clipboard` as `navigator.clipboard`, recording what the fallback seam receives. */
|
||||
async function copyWithClipboard(clipboard,{detach=false}={}) {
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),fallbacks=[];
|
||||
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
|
||||
Object.defineProperty(globalThis,'navigator',{configurable:true,value:clipboard===undefined?{}:{clipboard}});
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('exact text'),negative:record('')},
|
||||
copyFallback:(text,options)=>fallbacks.push({text,...options})});
|
||||
const copy=f.walk(container,node=>node.dataset.copyRole==='positive')[0];
|
||||
let current=copy;
|
||||
if(detach) {
|
||||
// The blocks re-render while the clipboard is pending: the clicked button is detached, its successor is not.
|
||||
copy.isConnected=false;container.querySelector=selector=>selector==='[data-copy-role="positive"]'?current:null;
|
||||
}
|
||||
const pending=container.listeners.get('click')({target:copy});
|
||||
if(detach) {view.update({positive:record('exact text'),negative:record('')});current=f.walk(container,node=>node.dataset.copyRole==='positive')[0];}
|
||||
await pending;
|
||||
return {fallbacks,toast:f.toast.textContent,copy,current,scope};
|
||||
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
|
||||
}
|
||||
test('copy without a clipboard opens the select-to-copy fallback and shows no toast',async()=>{
|
||||
const {fallbacks,toast,copy,scope}=await copyWithClipboard(undefined);
|
||||
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.equal(fallbacks[0].trigger,copy);assert.ok(fallbacks[0].scope);
|
||||
assert.notEqual(toast,'Prompt copied.');assert.equal(scope.signal.aborted,true);
|
||||
});
|
||||
test('a refused clipboard write opens the fallback instead of the toast',async()=>{
|
||||
const {fallbacks,toast}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}});
|
||||
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.notEqual(toast,'Prompt copied.');
|
||||
});
|
||||
test('a fallback after a re-render returns focus to the same-role copy button, not the detached one',async()=>{
|
||||
const {fallbacks,copy,current}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}},{detach:true});
|
||||
assert.equal(fallbacks.length,1);assert.notEqual(current,copy);assert.equal(fallbacks[0].trigger,current);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const modulePath='../../web/js/workspace/file-inspector.mjs';
|
||||
const load=()=>import(modulePath);
|
||||
const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
|
||||
function record() {
|
||||
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{category_scope:'pair',tags_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
}
|
||||
|
||||
test('metadata export retains literal raw and scoped evidence',async()=>{
|
||||
@@ -65,3 +65,24 @@ test('limited workflow record exports its representation and disables workflow d
|
||||
assert.equal(downloads.workflow.available,false);assert.equal(downloads.workflow.bodyText,null);
|
||||
assert.equal(downloads.workflow.reason,'Workflow exceeds inspection limits. Download the original file to inspect it externally.');
|
||||
});
|
||||
|
||||
test('Show in folder needs the full listing identity (D38)',async()=>{
|
||||
const {revealTarget}=await load(),scope='a'.repeat(64);
|
||||
assert.deepEqual(revealTarget(record().identity),{root_index:2,root_scope:scope,path:'a.png'});
|
||||
for(const identity of [
|
||||
{kind:'rooted-file',root_index:2,path:'a.png',root_scope:null},
|
||||
{kind:'rooted-file',root_index:'2',path:'a.png',root_scope:scope},
|
||||
{kind:'rooted-file',root_index:2,path:'',root_scope:scope},
|
||||
{kind:'reported-location',path:'folder/a.png',current_scope:scope},
|
||||
null,
|
||||
])assert.equal(revealTarget(identity),null);
|
||||
});
|
||||
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({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.');
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.
|
||||
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
|
||||
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
|
||||
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],category_scope:null,tags_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
|
||||
|
||||
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
|
||||
|
||||
@@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async()
|
||||
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('added terms split, trim and exact-diff like the concept',()=>{
|
||||
// The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly.
|
||||
assert.equal(typeof m.addedTerms,'function','addedTerms is exported');
|
||||
assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']);
|
||||
assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']);
|
||||
assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']);
|
||||
// Duplicates in the selected negative stay, as the concept lists every added term in order.
|
||||
assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']);
|
||||
// Case and inner whitespace are exact.
|
||||
assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']);
|
||||
assert.deepEqual(m.addedTerms('same, text','text, same'),[]);
|
||||
assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-category.mjs');
|
||||
|
||||
const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}});
|
||||
|
||||
test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => {
|
||||
const {categoryTargets} = await source;
|
||||
const images = [
|
||||
linked(7), linked(3), linked(7),
|
||||
{path:'u.png', linked:{status:'unlinked', positive_id:null}},
|
||||
{path:'a.png', linked:{status:'ambiguous', positive_id:4}},
|
||||
{path:'v.png', linked:{status:'unavailable', positive_id:null}},
|
||||
{path:'none.png'},
|
||||
{path:'bad.png', linked:{status:'linked', positive_id:'9'}},
|
||||
{path:'zero.png', linked:{status:'linked', positive_id:0}},
|
||||
{path:'frac.png', linked:{status:'linked', positive_id:1.5}},
|
||||
null,
|
||||
];
|
||||
assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8});
|
||||
assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0});
|
||||
assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0});
|
||||
});
|
||||
|
||||
test('the outcome toast counts the prompts and the skipped images', async () => {
|
||||
const {categoryOutcome} = await source;
|
||||
assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.');
|
||||
assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.');
|
||||
assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.');
|
||||
assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
|
||||
});
|
||||
|
||||
test('the dialog and failure copy is the plan\'s', async () => {
|
||||
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
|
||||
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
|
||||
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
|
||||
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
|
||||
});
|
||||
|
||||
test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => {
|
||||
const {updatedCount} = await source;
|
||||
assert.equal(updatedCount({updated_count:0}, 3), 0);
|
||||
assert.equal(updatedCount({updated_count:2}, 3), 2);
|
||||
for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) {
|
||||
assert.equal(updatedCount(result, 3), 3, JSON.stringify(result));
|
||||
}
|
||||
});
|
||||
|
||||
test('the category field is limited to the server\'s 100 characters', async () => {
|
||||
const {CATEGORY_MAX_LENGTH} = await source;
|
||||
assert.equal(CATEGORY_MAX_LENGTH, 100);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-export.mjs');
|
||||
const inspector = import('../../web/js/workspace/file-inspector.mjs');
|
||||
|
||||
function record(path, status = 'available') {
|
||||
return {identity:{kind:'rooted-file', key:`0:${path}`, root_index:0, path, root_scope:null}, file:{format:'PNG'},
|
||||
prompts:{positive:{status:'resolved', text:path}}, generation:{}, library:{status:'unlinked'},
|
||||
download:{filename:path.split('/').at(-1), url:`/prompt_manager/images/serve/${path}?root=0`},
|
||||
metadata:{status, omitted_reason:null, raw:{prompt:'{}'}, record_formats:{prompt:'original-text'}}};
|
||||
}
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => {resolve = a; reject = b;}); return {promise, resolve, reject}; };
|
||||
|
||||
test('the single-image download and the bundle share one envelope builder', async () => {
|
||||
const {metadataEnvelope, inspectionDownloads} = await inspector;
|
||||
const value = record('a/one.png');
|
||||
assert.deepEqual(JSON.parse(inspectionDownloads(value).metadata.bodyText), metadataEnvelope(value));
|
||||
assert.equal(metadataEnvelope(record('b.png', 'limited')), null);
|
||||
assert.equal(metadataEnvelope({}), null);
|
||||
});
|
||||
|
||||
test('the bundle holds the format, version, export time and envelopes in the given order', async () => {
|
||||
const {metadataBundle, BUNDLE_FILENAME} = await source;
|
||||
const envelopes = [{inspection_version:1, n:1}, {inspection_version:1, n:2}];
|
||||
const bundle = metadataBundle(envelopes, '2026-10-20T00:00:00.000Z');
|
||||
assert.deepEqual(bundle, {format:'prompt-manager-images-metadata', version:1, exported_at:'2026-10-20T00:00:00.000Z', images:envelopes});
|
||||
assert.notEqual(bundle.images, envelopes);
|
||||
assert.equal(BUNDLE_FILENAME, 'prompt-manager-images-metadata.json');
|
||||
});
|
||||
|
||||
test('collection keeps listing order, runs at most four reads at once and counts failures', async () => {
|
||||
const {collectImagesMetadata} = await source;
|
||||
const {metadataEnvelope} = await inspector;
|
||||
const images = Array.from({length:9}, (_, index) => ({path:`img-${index}.png`, root_index:0}));
|
||||
const waits = new Map(images.map(image => [image.path, deferred()]));
|
||||
let running = 0, peak = 0;
|
||||
const done = collectImagesMetadata(images, async image => {
|
||||
running += 1; peak = Math.max(peak, running);
|
||||
try { return await waits.get(image.path).promise; } finally { running -= 1; }
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(running, 4);
|
||||
// Settle out of order: later images finish first, one read fails and one file's metadata is limited.
|
||||
for (const image of [...images].reverse()) {
|
||||
const wait = waits.get(image.path);
|
||||
if (image.path === 'img-3.png') wait.reject(new Error('unreadable'));
|
||||
else wait.resolve(record(image.path, image.path === 'img-5.png' ? 'limited' : 'available'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
const {envelopes, failed} = await done;
|
||||
assert.equal(peak, 4);
|
||||
assert.equal(failed, 2);
|
||||
assert.deepEqual(envelopes.map(envelope => envelope.identity.path),
|
||||
['img-0.png', 'img-1.png', 'img-2.png', 'img-4.png', 'img-6.png', 'img-7.png', 'img-8.png']);
|
||||
assert.deepEqual(envelopes[0], metadataEnvelope(record('img-0.png')));
|
||||
});
|
||||
|
||||
test('a read that throws synchronously counts as a failure', async () => {
|
||||
const {collectImagesMetadata} = await source;
|
||||
const result = await collectImagesMetadata([{path:'a.png'}, {path:'b.png'}], image => {
|
||||
if (image.path === 'a.png') throw new TypeError('Invalid inspection identity');
|
||||
return record(image.path);
|
||||
});
|
||||
assert.equal(result.failed, 1);
|
||||
assert.deepEqual(result.envelopes.map(envelope => envelope.identity.path), ['b.png']);
|
||||
assert.deepEqual(await collectImagesMetadata([], () => assert.fail('no reads')), {envelopes:[], failed:0});
|
||||
});
|
||||
|
||||
test('the outcome toasts name the exported and unread counts', async () => {
|
||||
const {exportOutcome} = await source;
|
||||
assert.equal(exportOutcome(1, 1), 'Exported metadata for 1 image.');
|
||||
assert.equal(exportOutcome(2, 2), 'Exported metadata for 2 images.');
|
||||
assert.equal(exportOutcome(1, 3), 'Exported metadata for 1 of 3 images. 2 could not be read.');
|
||||
assert.equal(exportOutcome(2, 3), 'Exported metadata for 2 of 3 images. 1 could not be read.');
|
||||
assert.equal(exportOutcome(0, 2), 'Metadata could not be read. Try again.');
|
||||
});
|
||||
@@ -248,6 +248,15 @@ test('the scope chip summarizes source and folder', async () => {
|
||||
assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders');
|
||||
});
|
||||
|
||||
test('the Filters count names each active Refine filter once (D44)', async () => {
|
||||
const {activeRefinements}=await source;
|
||||
assert.equal(activeRefinements({root:'all',subfolder:'',media:'all',tags:[]}),0);
|
||||
assert.equal(activeRefinements({root:'',subfolder:'',media:'all',tags:[]}),0);
|
||||
assert.equal(activeRefinements({root:'1',subfolder:'',media:'all',tags:[]}),1);
|
||||
assert.equal(activeRefinements({root:'all',subfolder:'bulk',media:'video',tags:['a','b']}),3);
|
||||
assert.equal(activeRefinements({root:'0',subfolder:'bulk',media:'image',tags:['a']}),4);
|
||||
});
|
||||
|
||||
test('the card title is the confirmed linked title, else the filename (D10)', async () => {
|
||||
const {cardTitle}=await source;
|
||||
const linked=(status,title)=>({filename:'alpine_00001.png',linked:{status,positive_id:1,title,title_source:'title',category:null,rating:null}});
|
||||
@@ -313,3 +322,22 @@ test('withItem patches a saved browsing state without touching the other items',
|
||||
assert.equal(state.items[1].favorite, false);
|
||||
assert.equal(withItem(state, '1:b', {favorite:true}), state);
|
||||
});
|
||||
|
||||
test('toggleSelection returns a new frozen set and leaves the given one untouched', async () => {
|
||||
const { toggleSelection } = await source;
|
||||
const empty = Object.freeze(new Set());
|
||||
const one = toggleSelection(empty, '0:a', true);
|
||||
assert.ok(Object.isFrozen(one)); assert.deepEqual([...one], ['0:a']); assert.equal(empty.size, 0);
|
||||
const two = toggleSelection(one, '1:a', true);
|
||||
assert.deepEqual([...two], ['0:a', '1:a']); assert.deepEqual([...one], ['0:a']);
|
||||
assert.equal(toggleSelection(two, '0:a', true), two, 'an already selected key keeps the same set');
|
||||
assert.equal(toggleSelection(one, '9:z', false), one, 'an unselected key keeps the same set');
|
||||
assert.deepEqual([...toggleSelection(two, '0:a', false)], ['1:a']);
|
||||
});
|
||||
|
||||
test('selectionLabel counts the selected images as the concept bar does', async () => {
|
||||
const { selectionLabel } = await source;
|
||||
assert.equal(selectionLabel(1), '1 selected');
|
||||
assert.equal(selectionLabel(2), '2 selected');
|
||||
assert.equal(selectionLabel(1234), '1,234 selected');
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ function envelope(entry = image()) {
|
||||
workflow_graph: { status: 'absent', value: null }, parameters: null,
|
||||
},
|
||||
resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' },
|
||||
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' },
|
||||
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], category_scope: 'pair', tags_scope: 'pair' },
|
||||
};
|
||||
}
|
||||
function pendingApi() {
|
||||
@@ -276,6 +276,10 @@ test('record rejects root path media and invalid versioned fields with retryable
|
||||
data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; },
|
||||
data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; },
|
||||
data => { data.resolution.sampler_id = {}; },
|
||||
data => { data.library.tags_scope = 'recipe'; },
|
||||
data => { data.library.category_scope = 'recipe'; },
|
||||
data => { delete data.library.category_scope; },
|
||||
data => { delete data.library.tags_scope; },
|
||||
]) {
|
||||
const data = envelope(); mutate(data);
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/library-layout.mjs');
|
||||
|
||||
/** Runs `body` with the process time zone set to `zone`, restoring the previous one. */
|
||||
async function inZone(zone, body) {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = zone;
|
||||
try { await body(); } finally {
|
||||
if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test('used label boundaries in UTC and local time', async () => {
|
||||
const { usedLabel } = await source;
|
||||
await inZone('UTC', () => {
|
||||
const now = new Date('2026-10-20T12:00:00Z');
|
||||
assert.equal(usedLabel('2026-10-20T00:00:00Z', now), 'Used today');
|
||||
assert.equal(usedLabel('2026-10-20T11:59:59Z', now), 'Used today');
|
||||
assert.equal(usedLabel('2026-10-19T23:59:59Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-19T00:00:00Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-18T23:59:59Z', now), 'Used 2 days ago');
|
||||
assert.equal(usedLabel('2026-10-14T00:00:00Z', now), 'Used 6 days ago');
|
||||
assert.equal(usedLabel('2026-10-13T23:59:59Z', now), 'Used Oct 13');
|
||||
assert.equal(usedLabel('2026-01-02T08:00:00Z', now), 'Used Jan 2');
|
||||
// D23: a use in another calendar year names its year, so last year's Oct 21 never reads as this year's.
|
||||
assert.equal(usedLabel('2025-10-21T08:00:00Z', now), 'Used Oct 21, 2025');
|
||||
assert.equal(usedLabel('2025-12-31T23:59:59Z', now), 'Used Dec 31, 2025');
|
||||
// A clock ahead of this browser is not a future use: it reads as today.
|
||||
assert.equal(usedLabel('2026-10-21T09:00:00Z', now), 'Used today');
|
||||
});
|
||||
// The day boundary is the viewer's local midnight, not UTC's (PDT is UTC-7 in October).
|
||||
await inZone('America/Los_Angeles', () => {
|
||||
const now = new Date('2026-10-20T07:30:00Z');
|
||||
assert.equal(usedLabel('2026-10-20T07:10:00Z', now), 'Used today');
|
||||
assert.equal(usedLabel('2026-10-20T06:30:00Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-14T07:00:00Z', now), 'Used 6 days ago');
|
||||
assert.equal(usedLabel('2026-10-14T06:59:59Z', now), 'Used Oct 13');
|
||||
});
|
||||
// Across the November DST change a calendar day is still one day.
|
||||
await inZone('America/New_York', () => {
|
||||
const now = new Date('2026-11-02T05:30:00Z');
|
||||
assert.equal(usedLabel('2026-11-01T04:30:00Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-31T05:30:00Z', now), 'Used 2 days ago');
|
||||
});
|
||||
for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet');
|
||||
});
|
||||
|
||||
test('rating label omits unrated', async () => {
|
||||
const { ratingLabel } = await source;
|
||||
assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' });
|
||||
assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' });
|
||||
for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' });
|
||||
});
|
||||
@@ -2,12 +2,21 @@ const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const source=import('../../web/js/workspace/library-state.mjs');
|
||||
const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]});
|
||||
const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor});
|
||||
const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor});
|
||||
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
|
||||
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
|
||||
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
|
||||
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
|
||||
});
|
||||
test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{
|
||||
const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}];
|
||||
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.variantTotal,null);
|
||||
await s.reset();assert.equal(s.state.variantTotal,6);await s.loadMore();assert.equal(s.state.variantTotal,7);
|
||||
const reset=s.reset({text:'x'});assert.equal(s.state.variantTotal,null);await reset;assert.equal(s.state.variantTotal,1);
|
||||
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.variantTotal,1);restored.dispose();
|
||||
for(const bad of [undefined,-1,1.5,'2']){const broken=createLibraryStream({api:{get:async()=>({...page([item(1)]),variant_total:bad})}});await broken.reset();assert.ok(broken.state.error);assert.equal(broken.state.variantTotal,null);broken.dispose();}
|
||||
s.dispose();
|
||||
});
|
||||
test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{
|
||||
const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}});
|
||||
const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]);
|
||||
@@ -36,7 +45,7 @@ test('detail reads validate identities, abort stale selection and expose retryab
|
||||
test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{
|
||||
const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[];
|
||||
const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose();
|
||||
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}}));
|
||||
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}}));
|
||||
});
|
||||
test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{
|
||||
const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24);
|
||||
|
||||
@@ -40,8 +40,10 @@ function mountedDom() {
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
// As in the DOM, a string argument becomes a text node.
|
||||
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
|
||||
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
@@ -117,7 +119,7 @@ function currentInspectionEnvelope(target) {
|
||||
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
|
||||
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
|
||||
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null}};
|
||||
}
|
||||
|
||||
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
|
||||
|
||||
@@ -147,3 +147,16 @@ test('a Library or vocabulary write re-reads only the Library and Tags counts',
|
||||
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
|
||||
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
|
||||
});
|
||||
|
||||
test('navigating to the current route re-renders it, as the concept go() does', async () => {
|
||||
const {mountRouter,navigateTo}=await source;
|
||||
const win=new EventTarget();win.location={hash:'#/library/12'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
|
||||
navigateTo('#/library/12',win);
|
||||
assert.deepEqual(seen,[['library',12],['library',12]]);
|
||||
// A different route only sets the hash; its hashchange renders it once.
|
||||
navigateTo('#/library/13',win);assert.equal(win.location.hash,'#/library/13');assert.equal(seen.length,2);
|
||||
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
|
||||
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
|
||||
});
|
||||
|
||||
@@ -565,6 +565,47 @@ test('an action may open the next dialog without the late close event closing it
|
||||
}
|
||||
});
|
||||
|
||||
test('a content dialog shows page nodes and releases them once on close or replacement', async () => {
|
||||
const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
let closed = 0;
|
||||
const grid = new dom.Element('div');
|
||||
openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive');
|
||||
assert.deepEqual(byId(dom, 'modal-body').children, [grid]);
|
||||
assert.equal(modal(dom).open, true);
|
||||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
await tick();
|
||||
assert.equal(closed, 1);
|
||||
// Another dialog replacing the body releases it too, exactly once.
|
||||
openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ });
|
||||
openCopyFallback('swap', { scope });
|
||||
assert.equal(closed, 2);
|
||||
closeWorkspaceDialog();
|
||||
assert.equal(closed, 2);
|
||||
// Disposing the page scope closes it.
|
||||
openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ });
|
||||
scope.dispose();
|
||||
assert.equal(closed, 3);
|
||||
assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/);
|
||||
const other = (await coreModule).createScope();
|
||||
assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError);
|
||||
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError);
|
||||
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError);
|
||||
other.dispose();
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs need the page scope and close with it', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
@@ -611,3 +652,30 @@ test('every route change closes the shared workspace dialog', async () => {
|
||||
const body = render.slice(0, render.indexOf('viewScope = createScope();'));
|
||||
assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
|
||||
});
|
||||
|
||||
// D44: the concept has no indicator, so a filter set in "Refine this view" (or by Browse) was invisible on the page.
|
||||
test('the Filters button shows the active refinement count', async () => {
|
||||
const { mountToolbar } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: 2, onChange() {}, onFilters() {} });
|
||||
const filtersButton = container.children[0].children[3];
|
||||
// The visible text is the accessible name, so the count is spoken too.
|
||||
assert.equal(filtersButton.textContent, 'Filters · 2');
|
||||
toolbar.setActiveFilters(1);
|
||||
assert.equal(filtersButton.textContent, 'Filters · 1');
|
||||
toolbar.setActiveFilters(0);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: -1, onChange() {}, onFilters() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -279,6 +279,86 @@ class HistoryAPITests(ImageAPITestCase):
|
||||
response, body, 409, "history-changed", {"action": "refresh-history"}
|
||||
)
|
||||
|
||||
async def test_newest_pair_run_route_names_the_run_or_none(self):
|
||||
pair = self.api.db.save_prompt_pair("positive", "negative")
|
||||
quiet = self.api.db.save_prompt_pair("positive", "quiet")
|
||||
foreign = self.api.db.save_prompt_pair("elsewhere", "negative")
|
||||
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
self.capture(0, 100)
|
||||
newest = self.capture(1, 200)
|
||||
base = f"/prompt_manager/library/{positive}/history/pairs"
|
||||
response = await self.client.get(f"{base}/{pair}/newest")
|
||||
body = await response.json()
|
||||
self.assertEqual(response.status, 200, body)
|
||||
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
|
||||
self.assertEqual(body["history_version"], 1)
|
||||
self.assertEqual(body["history_epoch"], newest.storage_epoch)
|
||||
self.assertEqual(body["run"]["run_id"], newest.run_id)
|
||||
self.assertEqual(body["run"]["anchor_id"], "4")
|
||||
response = await self.client.get(f"{base}/{quiet}/newest")
|
||||
self.assertEqual((await response.json())["run"], None)
|
||||
response = await self.client.get(f"{base}/{foreign}/newest")
|
||||
self.assert_error(response, await response.json(), 404, "not-found")
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/library/999999/history/pairs/{pair}/newest"
|
||||
)
|
||||
self.assert_error(response, await response.json(), 404, "not-found")
|
||||
for path in (f"{base}/0/newest", f"{base}/01/newest", f"{base}/true/newest"):
|
||||
response = await self.client.get(path)
|
||||
self.assert_error(response, await response.json(), 400, "invalid-request")
|
||||
response = await self.client.get(f"{base}/{pair}/newest", params={"x": "1"})
|
||||
self.assert_error(response, await response.json(), 400, "invalid-request")
|
||||
|
||||
async def test_newest_pair_run_route_reports_storage_and_epoch_failures(self):
|
||||
pair = self.api.db.save_prompt_pair("positive", "negative")
|
||||
saved = self.api.db.get_prompt_pair(pair)
|
||||
self.capture(0, 100)
|
||||
url = (
|
||||
f"/prompt_manager/library/{saved['positive_prompt_id']}"
|
||||
f"/history/pairs/{pair}/newest"
|
||||
)
|
||||
# A prompt saved only as a negative is not a Library positive.
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/library/{saved['negative_prompt_id']}"
|
||||
f"/history/pairs/{pair}/newest"
|
||||
)
|
||||
self.assert_error(response, await response.json(), 404, "not-found")
|
||||
private = "SECRET_PROMPT /private/data/secret.db"
|
||||
for exc in (sqlite3.OperationalError("busy " + private), OSError(private)):
|
||||
with patch.object(self.api.db, "get_newest_pair_run", side_effect=exc):
|
||||
with self.assertLogs("prompt_manager.api", "WARNING") as logs:
|
||||
response = await self.client.get(url)
|
||||
body = await response.json()
|
||||
self.assert_error(
|
||||
response, body, 503, "storage-unavailable", {"action": "retry-read"}
|
||||
)
|
||||
self.assertNotIn(private, json.dumps(body) + " ".join(logs.output))
|
||||
source = str(self.comfy_dir / "source.db")
|
||||
self.assertTrue(self.api.db.model.backup_database(source))
|
||||
writer = PromptDatabase(self.api.db.model.db_path)
|
||||
original = self.api.db.get_newest_pair_run
|
||||
|
||||
def during_snapshot(*args, **kwargs):
|
||||
result = original(*args, **kwargs)
|
||||
with patch("database.execution_history.notify_history_restore"):
|
||||
writer.model.restore_from_file(source)
|
||||
return result
|
||||
|
||||
try:
|
||||
with patch.object(
|
||||
self.api.db, "get_newest_pair_run", side_effect=during_snapshot
|
||||
):
|
||||
response = await self.client.get(url)
|
||||
self.assert_error(
|
||||
response,
|
||||
await response.json(),
|
||||
409,
|
||||
"history-changed",
|
||||
{"action": "refresh-history"},
|
||||
)
|
||||
finally:
|
||||
writer.close_all()
|
||||
|
||||
async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self):
|
||||
start = make_start(
|
||||
SESSION,
|
||||
|
||||
@@ -375,12 +375,55 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
result["image"],
|
||||
{"root_index": 0, "path": relative, "filename": "猫 % #.png"},
|
||||
)
|
||||
self.assertEqual(result["library"]["metadata_scope"], "pair")
|
||||
# D39: the recipe has no category or tags, so both fall back.
|
||||
self.assertEqual(result["library"]["category_scope"], "positive")
|
||||
self.assertEqual(result["library"]["tags_scope"], "positive")
|
||||
self.assertEqual(result["library"]["pair_id"], pair)
|
||||
self.assertEqual(result["library"]["positive_id"], positive)
|
||||
# D10/D20: the linked positive's title; "Pair title" names the variant.
|
||||
self.assertEqual(result["library"]["title"], "Positive title")
|
||||
|
||||
async def test_library_gallery_reports_favorites_with_one_lookup_per_page(self):
|
||||
# The Library-embedded media cards carry the Images star (#249 PR E):
|
||||
# favorites come from the store keyed by root and relative path.
|
||||
pair = self.api.db.save_prompt_pair("Cat", "Noise")
|
||||
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
for index in range(5):
|
||||
path = make_png(self.output_dir / "sub" / f"{index}.png")
|
||||
image = self.api.db.link_image_to_prompt(positive, str(path))
|
||||
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
response = await self.client.put(
|
||||
"/prompt_manager/images/favorite",
|
||||
json={"root_index": 0, "path": "sub/3.png"},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
lookups = []
|
||||
original = self.api.db.get_image_favorites
|
||||
|
||||
def counted(root_key, paths):
|
||||
lookups.append(list(paths))
|
||||
return original(root_key, paths)
|
||||
|
||||
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
|
||||
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
|
||||
stars = {image["path"]: image["favorite"] for image in gallery["images"]}
|
||||
self.assertEqual(stars, {f"sub/{index}.png": index == 3 for index in range(5)})
|
||||
# One batched read per page, never one per card.
|
||||
self.assertEqual([len(paths) for paths in lookups], [5])
|
||||
# Shelf previews show no star and read no favorites.
|
||||
lookups.clear()
|
||||
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
|
||||
_, listing = await self.get()
|
||||
self.assertEqual(lookups, [])
|
||||
self.assertNotIn("favorite", listing["items"][0]["previews"][0])
|
||||
response = await self.client.delete(
|
||||
"/prompt_manager/images/favorite",
|
||||
json={"root_index": 0, "path": "sub/3.png"},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
|
||||
self.assertFalse(any(image["favorite"] for image in gallery["images"]))
|
||||
|
||||
async def test_epoch_scoped_all_library_cursors(self):
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
|
||||
|
||||
@@ -1302,6 +1302,64 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
)
|
||||
roots.assert_called_once_with()
|
||||
|
||||
async def test_recipe_link_falls_back_to_positive_category_and_tags_per_field(
|
||||
self,
|
||||
):
|
||||
# D39 ruling (2026-10-21): a recipe's own category and tags win when set;
|
||||
# otherwise each field falls back to the linked positive's, separately, and
|
||||
# the scopes say where each value came from (no merging of tag sets).
|
||||
cases = (
|
||||
("Recipe cat", ["recipe"], "Recipe cat", "pair", ["recipe"], "pair"),
|
||||
(None, [], "Positive cat", "positive", ["positive"], "positive"),
|
||||
(" ", [], "Positive cat", "positive", ["positive"], "positive"),
|
||||
("Recipe cat", [], "Recipe cat", "pair", ["positive"], "positive"),
|
||||
(None, ["recipe"], "Positive cat", "positive", ["recipe"], "pair"),
|
||||
)
|
||||
for index, case in enumerate(cases):
|
||||
recipe_category, recipe_tags = case[:2]
|
||||
category, category_scope, tags, tags_scope = case[2:]
|
||||
with self.subTest(case=index):
|
||||
text = f"Fallback owner {index}"
|
||||
positive = self._save_prompt(
|
||||
text, category="Positive cat", tags=["positive"]
|
||||
)
|
||||
pair = self.api.db.save_prompt_pair(
|
||||
text,
|
||||
f"Fallback noise {index}",
|
||||
category=recipe_category,
|
||||
tags=recipe_tags,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_pair(pair)["positive_prompt_id"], positive
|
||||
)
|
||||
filename = f"fallback-{index}.png"
|
||||
path = png(self.output_dir / filename)
|
||||
image = self.api.db.link_image_to_prompt(positive, str(path))
|
||||
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
response, result = await self.inspect(filename)
|
||||
self.assertEqual(response.status, 200)
|
||||
library = result["library"]
|
||||
self.assertEqual(library["status"], "linked")
|
||||
self.assertEqual(library["pair_id"], pair)
|
||||
self.assertEqual(library["category"], category)
|
||||
self.assertEqual(library["category_scope"], category_scope)
|
||||
self.assertEqual(library["tags"], tags)
|
||||
self.assertEqual(library["tags_scope"], tags_scope)
|
||||
self.assertNotIn("metadata_scope", library)
|
||||
|
||||
async def test_positive_only_link_keeps_positive_scopes(self):
|
||||
positive = self._save_prompt("Solo owner", tags=["solo"])
|
||||
path = png(self.output_dir / "solo.png")
|
||||
self.api.db.link_image_to_prompt(positive, str(path))
|
||||
response, result = await self.inspect("solo.png")
|
||||
self.assertEqual(response.status, 200)
|
||||
library = result["library"]
|
||||
self.assertEqual(library["pair_id"], None)
|
||||
self.assertEqual(library["category"], None)
|
||||
self.assertEqual(library["category_scope"], "positive")
|
||||
self.assertEqual(library["tags"], ["solo"])
|
||||
self.assertEqual(library["tags_scope"], "positive")
|
||||
|
||||
async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence(
|
||||
self,
|
||||
):
|
||||
@@ -1340,8 +1398,9 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
if count == 1:
|
||||
self.assertEqual(result["library"]["positive_id"], positive)
|
||||
self.assertEqual(
|
||||
result["library"]["metadata_scope"], "positive"
|
||||
result["library"]["category_scope"], "positive"
|
||||
)
|
||||
self.assertEqual(result["library"]["tags_scope"], "positive")
|
||||
self.assertEqual(result["library"]["category"], "Category 1")
|
||||
self.assertEqual(result["library"]["tags"], ["owner-1"])
|
||||
if not readable:
|
||||
@@ -1361,7 +1420,8 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
},
|
||||
)
|
||||
for field in (
|
||||
|
||||
@@ -26,6 +26,7 @@ from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
import py.api.prompts as prompts_module # noqa: E402
|
||||
from py.api.prompts import ( # noqa: E402
|
||||
BULK_CATEGORY_MAX_PROMPTS,
|
||||
MAX_PAGE_LIMIT,
|
||||
MAX_PAGE_OFFSET,
|
||||
safe_error_message,
|
||||
@@ -909,6 +910,98 @@ class TestBulkRoutes(RouteCoverageCase):
|
||||
self.assertEqual(ok_data["updated_count"], 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c")
|
||||
|
||||
async def _set_category(self, body):
|
||||
return await self._json("POST", "/prompt_manager/bulk/category", json=body)
|
||||
|
||||
async def test_bulk_set_category_rejects_malformed_prompt_ids(self):
|
||||
pid = self._save_prompt("bulk cat strict")
|
||||
cases = (
|
||||
None,
|
||||
"1",
|
||||
{"id": pid},
|
||||
[],
|
||||
[pid, "2"],
|
||||
[pid, 0],
|
||||
[pid, -3],
|
||||
[pid, 1.5],
|
||||
[True],
|
||||
[pid, None],
|
||||
# Beyond SQLite's INTEGER range: binding it would raise OverflowError.
|
||||
[pid, 2**63],
|
||||
[2**64],
|
||||
list(range(1, BULK_CATEGORY_MAX_PROMPTS + 2)),
|
||||
)
|
||||
for prompt_ids in cases:
|
||||
with self.subTest(prompt_ids=str(prompt_ids)[:40]):
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": prompt_ids, "category": "c"}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
|
||||
|
||||
async def test_bulk_set_category_validates_the_category(self):
|
||||
pid = self._save_prompt("bulk cat validate")
|
||||
for category in ("x" * 101, "bad\x00name", 7, ["c"]):
|
||||
with self.subTest(category=str(category)[:20]):
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": [pid], "category": category}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
for body in ([pid], "text"):
|
||||
with self.subTest(body=body):
|
||||
status, data = await self._set_category(body)
|
||||
self.assertEqual(status, 400, data)
|
||||
resp = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
data="{not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
|
||||
|
||||
async def test_bulk_set_category_dedupes_and_accepts_the_cap(self):
|
||||
ids = [self._save_prompt(f"bulk cat dedupe {i}") for i in range(2)]
|
||||
seen = []
|
||||
original = self.api.db.bulk_set_category
|
||||
|
||||
def spy(prompt_ids, category):
|
||||
seen.append((list(prompt_ids), category))
|
||||
return original(prompt_ids, category)
|
||||
|
||||
self.api.db.bulk_set_category = spy
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": [ids[1], ids[0], ids[1], ids[0]], "category": " Land "}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["updated_count"], 2)
|
||||
self.assertEqual(seen, [([ids[1], ids[0]], "Land")])
|
||||
# The cap itself is accepted; unknown ids simply do not count.
|
||||
capped = ids + list(range(10**6, 10**6 + BULK_CATEGORY_MAX_PROMPTS - 2))
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": capped, "category": "Sea"}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["updated_count"], 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "Sea")
|
||||
|
||||
async def test_bulk_set_category_keeps_the_legacy_admin_request(self):
|
||||
# web/js/admin.js posts parseInt ids and a trimmed, non-empty category.
|
||||
ids = [self._save_prompt(f"bulk cat legacy {i}") for i in range(3)]
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": ids, "category": "Portraits"}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(
|
||||
set(data), {"success", "message", "updated_count"}, "response shape"
|
||||
)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["updated_count"], 3)
|
||||
self.assertEqual(data["message"], "Set category for 3 prompts")
|
||||
for pid in ids:
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Portraits")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1092,6 +1092,27 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
db.link_image_to_prompt(owner, str(files["ambiguous"]))
|
||||
return files
|
||||
|
||||
async def test_bulk_category_change_shows_on_the_next_listing(self):
|
||||
files = self._filter_fixtures()
|
||||
db = self.api.db
|
||||
night_a = db.get_prompt_id_for_image(str(files["night_a"]))
|
||||
# Warm every listing cache (scan, orders, card facts, link reads).
|
||||
for params in ({}, {"category": "Landscapes"}, {"category": "Night"}):
|
||||
response, data = await self.get(**params)
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(data["total"], 3)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
json={"prompt_ids": [night_a], "category": "Landscapes"},
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.json())
|
||||
_, moved = await self.get(category="Landscapes")
|
||||
self.assertEqual(self._names(moved), ["night_a.png"])
|
||||
self.assertEqual(moved["images"][0]["linked"]["category"], "Landscapes")
|
||||
_, night = await self.get(category="Night")
|
||||
self.assertEqual(night["total"], 2)
|
||||
self.assertNotIn("night_a.png", self._names(night))
|
||||
|
||||
async def test_positive_count_counts_distinct_linked_positives(self):
|
||||
files = self._filter_fixtures()
|
||||
db = self.api.db
|
||||
|
||||
@@ -24,6 +24,11 @@ except ImportError:
|
||||
import test_execution_history_writer as fixtures
|
||||
|
||||
|
||||
# Upper bound a blocked fake write waits for its release. Tests prove ordering
|
||||
# with events, so this only caps a failing run and never decides a pass.
|
||||
BLOCK_SECONDS = 10
|
||||
|
||||
|
||||
class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
setUp = fixtures.HistoryWriterTests.setUp
|
||||
cleanup_workers = fixtures.HistoryWriterTests.cleanup_workers
|
||||
@@ -408,59 +413,81 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
def test_bounded_close_and_early_native_join_keep_inflight_combined_charge(self):
|
||||
for early in (False, True):
|
||||
with self.subTest(early_join=early):
|
||||
writer = self.writer()
|
||||
start = self.start_command(len(self.writers))
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
original = self.db.write_execution_outputs_for_finish
|
||||
self.assert_bounded_close_keeps_inflight_charge(early)
|
||||
|
||||
def write(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
return original(*args, **kwargs)
|
||||
def assert_bounded_close_keeps_inflight_charge(self, early):
|
||||
# Every writer in this test charges the process-wide budget, so a
|
||||
# failing subtest must not leave its worker blocked (and charged) for
|
||||
# the next one: release and join it before the next subtest starts.
|
||||
writer = self.writer()
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
start = self.start_command(len(self.writers))
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
left = threading.Event()
|
||||
original = self.db.write_execution_outputs_for_finish
|
||||
|
||||
with patch.object(self.db, "write_execution_outputs_for_finish", write):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(1))
|
||||
charge = writer.context_budget.charged_bytes
|
||||
self.finalization(writer, start)
|
||||
clock = fixtures.ManualClock()
|
||||
native_join = writer._thread.join
|
||||
def write(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(BLOCK_SECONDS)
|
||||
left.set()
|
||||
return original(*args, **kwargs)
|
||||
|
||||
def return_early(timeout):
|
||||
native_join(0)
|
||||
clock.advance(min(timeout, 0.004))
|
||||
try:
|
||||
with patch.object(self.db, "write_execution_outputs_for_finish", write):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(BLOCK_SECONDS))
|
||||
charge = writer.context_budget.charged_bytes
|
||||
self.finalization(writer, start)
|
||||
clock = fixtures.ManualClock()
|
||||
native_join = writer._thread.join
|
||||
|
||||
def sleep(delay):
|
||||
clock.advance(delay)
|
||||
def return_early(timeout):
|
||||
native_join(0)
|
||||
clock.advance(min(timeout, 0.004))
|
||||
|
||||
clock_patch = (
|
||||
patch(
|
||||
"utils.execution_history_writer.time",
|
||||
SimpleNamespace(monotonic=clock, sleep=sleep),
|
||||
)
|
||||
if early
|
||||
else nullcontext()
|
||||
def sleep(delay):
|
||||
clock.advance(delay)
|
||||
|
||||
clock_patch = (
|
||||
patch(
|
||||
"utils.execution_history_writer.time",
|
||||
SimpleNamespace(monotonic=clock, sleep=sleep),
|
||||
)
|
||||
join_patch = (
|
||||
patch.object(writer._thread, "join", return_early)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
with clock_patch, join_patch:
|
||||
before = clock() if early else time.monotonic()
|
||||
self.assertFalse(writer.close(0.02))
|
||||
elapsed = (clock() if early else time.monotonic()) - before
|
||||
self.assertGreaterEqual(elapsed, 0.02)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
join_patch = (
|
||||
patch.object(writer._thread, "join", return_early)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
with clock_patch, join_patch:
|
||||
before = clock() if early else time.monotonic()
|
||||
self.assertFalse(writer.close(0.02))
|
||||
elapsed = (clock() if early else time.monotonic()) - before
|
||||
# close() waits until its own monotonic deadline, so the lower
|
||||
# bound holds on any runner.
|
||||
self.assertGreaterEqual(elapsed, 0.02)
|
||||
if early:
|
||||
# Manual clock: only the patched join/sleep advance it, so
|
||||
# this exact bound proves early joins do not spin forever.
|
||||
self.assertLess(elapsed, 0.2)
|
||||
self.assertEqual(writer.context_budget.charged_bytes, charge)
|
||||
replacement = HistoryWriter(lambda: self.db)
|
||||
self.writers.append(replacement)
|
||||
self.assertIs(replacement.context_budget, writer.context_budget)
|
||||
self.assertIsNone(replacement.context_budget.reserve(8388608))
|
||||
release.set()
|
||||
self.assertTrue(writer.close(2))
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
# Bounded, not unbounded: close() gave up while the in-flight
|
||||
# write was still blocked, instead of waiting it out.
|
||||
self.assertFalse(left.is_set())
|
||||
self.assertTrue(writer._thread.is_alive())
|
||||
self.assertEqual(writer.context_budget.charged_bytes, charge)
|
||||
replacement = HistoryWriter(lambda: self.db)
|
||||
self.writers.append(replacement)
|
||||
self.assertIs(replacement.context_budget, writer.context_budget)
|
||||
self.assertIsNone(replacement.context_budget.reserve(8388608))
|
||||
release.set()
|
||||
self.assertTrue(writer.close(2))
|
||||
finally:
|
||||
release.set()
|
||||
writer.close(BLOCK_SECONDS)
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
|
||||
def test_pending_saturation_and_invalid_command_release_both_payloads(self):
|
||||
writer = self.writer()
|
||||
@@ -552,11 +579,13 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
invalidating, invalidated = threading.Event(), threading.Event()
|
||||
committed = threading.Event()
|
||||
original = outputs_db._capture
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
release.wait(BLOCK_SECONDS)
|
||||
committed.set()
|
||||
return original(*args, **kwargs)
|
||||
|
||||
def invalidate():
|
||||
@@ -566,20 +595,24 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
|
||||
with patch.object(outputs_db, "_capture", capture):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(1))
|
||||
self.assertTrue(entered.wait(BLOCK_SECONDS))
|
||||
invalidator = threading.Thread(target=invalidate)
|
||||
invalidator.start()
|
||||
try:
|
||||
self.assertTrue(invalidating.wait(1))
|
||||
self.assertTrue(invalidating.wait(BLOCK_SECONDS))
|
||||
self.assertFalse(invalidated.wait(0.03))
|
||||
before = time.monotonic()
|
||||
self.assertTrue(writer.submit(self.finish_command(start)))
|
||||
self.assertFalse(writer.close(0))
|
||||
self.assertLess(time.monotonic() - before, 0.1)
|
||||
# submit() and close() returned while the commit still held the
|
||||
# gate: the blocked invalidation has not finished and the
|
||||
# commit is still waiting for its release. Had either call
|
||||
# queued behind the gate, it could only return after both.
|
||||
self.assertFalse(invalidated.is_set())
|
||||
self.assertFalse(committed.is_set())
|
||||
self.assertGreater(writer.context_budget.charged_bytes, 0)
|
||||
finally:
|
||||
release.set()
|
||||
invalidator.join(3)
|
||||
invalidator.join(BLOCK_SECONDS)
|
||||
self.assertFalse(invalidator.is_alive())
|
||||
self.assertTrue(writer.close(2))
|
||||
self.assertEqual(
|
||||
|
||||
@@ -16,6 +16,7 @@ from tests.test_api_images import make_png
|
||||
from database.operations import PromptDatabase
|
||||
from py.config import GalleryConfig
|
||||
from utils.execution_outputs import snapshot_output_roots
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class CurrentLocationTests(unittest.TestCase):
|
||||
@@ -331,6 +332,93 @@ class CurrentLocationTests(unittest.TestCase):
|
||||
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
|
||||
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
|
||||
|
||||
def test_gallery_recipe_display_falls_back_to_positive_per_field(self):
|
||||
# D39 ruling (2026-10-21), shared with the Images inspector.
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
cases = (
|
||||
("Recipe cat", ["recipe"], ("Recipe cat", "pair", ["recipe"], "pair")),
|
||||
(None, [], ("Positive cat", "positive", ["positive"], "positive")),
|
||||
("Recipe cat", [], ("Recipe cat", "pair", ["positive"], "positive")),
|
||||
("", ["recipe"], ("Positive cat", "positive", ["recipe"], "pair")),
|
||||
)
|
||||
for index, (recipe_category, recipe_tags, expected) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
text = f"owner {index}"
|
||||
positive = db.save_prompt(
|
||||
text,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
category="Positive cat",
|
||||
)
|
||||
db.set_prompt_tags(positive, ["positive"])
|
||||
pair = db.save_prompt_pair(
|
||||
text, "noise", category=recipe_category, tags=recipe_tags
|
||||
)
|
||||
path = make_png(self.output / f"fallback-{index}.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
image = db.link_image_to_prompt(positive, str(path))
|
||||
db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
conn = db.model.get_connection()
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))
|
||||
library = result[locator]["library"]
|
||||
self.assertEqual(library["pair_id"], pair)
|
||||
self.assertEqual(
|
||||
(
|
||||
library["category"],
|
||||
library["category_scope"],
|
||||
library["tags"],
|
||||
library["tags_scope"],
|
||||
),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_gallery_blank_recipe_category_and_skipped_positive_tags_query(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "blank.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
cases = (
|
||||
(" ", ["recipe"], 0),
|
||||
("\u3000", [], 1),
|
||||
("\u00a0 ", ["recipe"], 0),
|
||||
)
|
||||
for index, (blank, recipe_tags, positive_tag_queries) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
text = f"blank owner {index}"
|
||||
positive = db.save_prompt(
|
||||
text,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
category="Positive cat",
|
||||
)
|
||||
db.set_prompt_tags(positive, ["positive"])
|
||||
pair = db.save_prompt_pair(text, "noise", tags=recipe_tags)
|
||||
path = make_png(self.output / f"blank-{index}.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
image = db.link_image_to_prompt(positive, str(path))
|
||||
db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
conn = db.model.get_connection()
|
||||
# Legacy rows can hold a blank-looking category the writers now trim.
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET category=? WHERE id=?", (blank, pair)
|
||||
)
|
||||
conn.commit()
|
||||
statements = []
|
||||
conn.set_trace_callback(statements.append)
|
||||
self.addCleanup(conn.set_trace_callback, None)
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))
|
||||
conn.set_trace_callback(None)
|
||||
library = result[locator]["library"]
|
||||
self.assertEqual(library["category"], "Positive cat")
|
||||
self.assertEqual(library["category_scope"], "positive")
|
||||
tag_queries = [
|
||||
statement
|
||||
for statement in statements
|
||||
if "FROM prompt_tags pt" in statement
|
||||
]
|
||||
self.assertEqual(len(tag_queries), positive_tag_queries, statements)
|
||||
|
||||
def test_host_root_failure_and_removal_do_not_search_another_root(self):
|
||||
make_png(self.output / "image.png")
|
||||
make_png(self.temp / "image.png")
|
||||
|
||||
@@ -913,3 +913,79 @@ class HistoryReadTests(unittest.TestCase):
|
||||
db.list_history_outputs(
|
||||
start.run_id, after=OutputPosition("0" * 64, (node, 0))
|
||||
)
|
||||
|
||||
def test_newest_pair_run_follows_current_hints_in_the_raw_scope(self):
|
||||
db = self.database()
|
||||
pair_a = db.save_prompt_pair("positive", "A")
|
||||
pair_b = db.save_prompt_pair("positive", "B")
|
||||
quiet = db.save_prompt_pair("positive", "C")
|
||||
other = db.save_prompt_pair("elsewhere", "A")
|
||||
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
|
||||
self.capture(db, 0, bundle("A"), time=100)
|
||||
newest = self.capture(db, 1, bundle("A"), time=200)
|
||||
b = self.capture(db, 2, bundle("B"), time=300)
|
||||
with db.recorded_history_snapshot():
|
||||
found = db.get_newest_pair_run(positive, pair_a)
|
||||
found_b = db.get_newest_pair_run(positive, pair_b)
|
||||
none = db.get_newest_pair_run(positive, quiet)
|
||||
foreign = db.get_newest_pair_run(positive, other)
|
||||
missing = db.get_newest_pair_run(9999, pair_a)
|
||||
self.assertEqual(found["run"]["run_id"], newest.run_id)
|
||||
self.assertEqual(found["run"]["anchor_id"], "4")
|
||||
self.assertRegex(found["run"]["context_id"], "^[0-9a-f]{64}$")
|
||||
self.assertEqual(found_b["run"]["run_id"], b.run_id)
|
||||
self.assertEqual(none, {"run": None})
|
||||
self.assertIsNone(foreign)
|
||||
self.assertIsNone(missing)
|
||||
# A hint that no longer names the current pair is not followed.
|
||||
db.delete_prompt_pair(pair_b)
|
||||
recreated = db.save_prompt_pair("positive", "B")
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(db.get_newest_pair_run(positive, recreated), {"run": None})
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
db.get_newest_pair_run(positive, pair_a)
|
||||
|
||||
def test_newest_pair_run_skips_stale_epoch_and_bounds_stale_hints(self):
|
||||
db = self.database()
|
||||
pair_a = db.save_prompt_pair("positive", "A")
|
||||
pair_b = db.save_prompt_pair("positive", "B")
|
||||
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
|
||||
older = self.capture(db, 0, bundle("A"), time=100)
|
||||
stale_epoch = self.capture(db, 1, bundle("A"), time=200)
|
||||
conn = db.model.get_connection()
|
||||
# A row whose reference epoch is not the current one is never followed.
|
||||
conn.execute(
|
||||
"UPDATE execution_run_contexts SET reference_epoch=? WHERE run_id=?",
|
||||
("00000000-0000-4000-8000-000000000000", stale_epoch.run_id),
|
||||
)
|
||||
conn.commit()
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(
|
||||
db.get_newest_pair_run(positive, pair_a)["run"]["run_id"],
|
||||
older.run_id,
|
||||
)
|
||||
# Seventeen newer rows whose hint names pair A but whose texts resolve to
|
||||
# pair B fill the candidate bound, so the older valid run is not reached.
|
||||
for item in range(2, 19):
|
||||
start = self.capture(db, item, bundle("B"), time=300 + item)
|
||||
conn.execute(
|
||||
"UPDATE execution_run_contexts SET pair_id=? WHERE run_id=?",
|
||||
(pair_a, start.run_id),
|
||||
)
|
||||
conn.commit()
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(db.get_newest_pair_run(positive, pair_a), {"run": None})
|
||||
self.assertIsNotNone(db.get_newest_pair_run(positive, pair_b))
|
||||
|
||||
def test_newest_pair_run_rejects_an_ineligible_or_deleted_positive(self):
|
||||
db = self.database()
|
||||
pair = db.save_prompt_pair("positive", "negative")
|
||||
saved = db.get_prompt_pair(pair)
|
||||
self.capture(db, 0, bundle("negative"), time=100)
|
||||
with db.recorded_history_snapshot():
|
||||
# A prompt saved only as a pair's negative is not a Library positive.
|
||||
self.assertIsNone(db.get_newest_pair_run(saved["negative_prompt_id"], pair))
|
||||
db.delete_prompt_pair(pair)
|
||||
db.delete_prompt(saved["positive_prompt_id"])
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertIsNone(db.get_newest_pair_run(saved["positive_prompt_id"], pair))
|
||||
|
||||
@@ -0,0 +1,919 @@
|
||||
"""Show in folder (TESTING.md D38): the local-only reveal route and its openers.
|
||||
|
||||
No test launches a file manager: ``subprocess.Popen``, ``sys.platform`` and
|
||||
``shutil.which`` are mocked, and route tests swap the API's opener.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from aiohttp import web
|
||||
from multidict import CIMultiDict
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
|
||||
import utils.file_reveal as file_reveal
|
||||
from utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
is_local_request,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
|
||||
REVEAL = "/prompt_manager/images/reveal"
|
||||
PROXY_HEADERS = (
|
||||
"Forwarded",
|
||||
"Via",
|
||||
"X-Forwarded-For",
|
||||
"X-Forwarded-Host",
|
||||
"X-Forwarded-Proto",
|
||||
"X-Forwarded-Port",
|
||||
"X-Forwarded-Server",
|
||||
"X-Real-IP",
|
||||
"X-Original-Forwarded-For",
|
||||
"X-Original-Host",
|
||||
"CF-Connecting-IP",
|
||||
"True-Client-IP",
|
||||
"X-Client-IP",
|
||||
"X-Cluster-Client-IP",
|
||||
"Fastly-Client-IP",
|
||||
)
|
||||
ORIGIN = "http://127.0.0.1:8188"
|
||||
|
||||
|
||||
def headers(**values):
|
||||
base = {"Host": "127.0.0.1:8188", "Origin": ORIGIN}
|
||||
base.update({key.replace("_", "-"): value for key, value in values.items()})
|
||||
return CIMultiDict({key: value for key, value in base.items() if value is not None})
|
||||
|
||||
|
||||
class TestIsLocalRequest(unittest.TestCase):
|
||||
def test_same_origin_loopback_clients_by_loopback_name_are_local(self):
|
||||
for remote, host, origin in (
|
||||
("127.0.0.1", "127.0.0.1:8188", ORIGIN),
|
||||
("127.8.9.10", "localhost:8188", "http://localhost:8188"),
|
||||
("::1", "[::1]:8188", "http://[::1]:8188"),
|
||||
("::ffff:127.0.0.1", "comfy.localhost", "http://comfy.localhost"),
|
||||
("127.0.0.1", "LOCALHOST:8188", "http://localhost:8188"),
|
||||
("127.0.0.1", "localhost", "http://localhost:80"),
|
||||
):
|
||||
with self.subTest(remote=remote, host=host):
|
||||
self.assertTrue(
|
||||
is_local_request(remote, headers(Host=host, Origin=origin))
|
||||
)
|
||||
self.assertTrue(
|
||||
is_local_request(
|
||||
"127.0.0.1",
|
||||
headers(Host="localhost", Origin="https://localhost:443"),
|
||||
"https",
|
||||
)
|
||||
)
|
||||
|
||||
def test_non_loopback_or_unknown_remote_is_not_local(self):
|
||||
for remote in ("192.168.1.5", "10.0.0.1", "::2", "fe80::1%eth0", "", None):
|
||||
with self.subTest(remote=remote):
|
||||
self.assertFalse(is_local_request(remote, headers()))
|
||||
|
||||
def test_any_forwarding_header_makes_loopback_remote(self):
|
||||
for name in (*PROXY_HEADERS, "X-Forwarded-Anything", "X-Original-URI"):
|
||||
for spelling in (name, name.lower()):
|
||||
with self.subTest(header=spelling):
|
||||
request_headers = headers()
|
||||
request_headers[spelling] = "127.0.0.1"
|
||||
self.assertFalse(is_local_request("127.0.0.1", request_headers))
|
||||
|
||||
def test_origin_is_required_and_must_be_the_request_origin(self):
|
||||
# A header-less proxy or curl leaves Origin out; a page on another
|
||||
# loopback port or scheme sends its own (security review M1, L2).
|
||||
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=None)))
|
||||
for origin in (
|
||||
"",
|
||||
"null",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1",
|
||||
"https://127.0.0.1:8188",
|
||||
"http://localhost:8188",
|
||||
"http://foo.localhost:8188",
|
||||
"http://evil.example:8188",
|
||||
"http://[bad",
|
||||
"http://127.0.0.1:8188/path",
|
||||
"http://user@127.0.0.1:8188",
|
||||
"file://127.0.0.1:8188",
|
||||
):
|
||||
with self.subTest(origin=origin):
|
||||
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=origin)))
|
||||
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=ORIGIN), "https"))
|
||||
|
||||
def test_sec_fetch_site_must_be_same_origin_when_sent(self):
|
||||
self.assertTrue(
|
||||
is_local_request("127.0.0.1", headers(Sec_Fetch_Site="same-origin"))
|
||||
)
|
||||
for site in ("same-site", "cross-site", "none", ""):
|
||||
with self.subTest(site=site):
|
||||
self.assertFalse(
|
||||
is_local_request("127.0.0.1", headers(Sec_Fetch_Site=site))
|
||||
)
|
||||
|
||||
def test_foreign_host_is_not_local(self):
|
||||
# A DNS-rebinding page reaches 127.0.0.1 under its own name.
|
||||
for host in ("evil.example:8188", "", "[bad", "127.0.0.1:bad"):
|
||||
with self.subTest(host=host):
|
||||
self.assertFalse(
|
||||
is_local_request(
|
||||
"127.0.0.1", headers(Host=host, Origin=f"http://{host}")
|
||||
)
|
||||
)
|
||||
self.assertFalse(is_local_request("127.0.0.1", CIMultiDict({"Origin": ORIGIN})))
|
||||
|
||||
|
||||
class TestRevealGate(unittest.TestCase):
|
||||
def test_one_reveal_at_a_time_then_a_cooldown(self):
|
||||
now = [100.0]
|
||||
gate = file_reveal.RevealGate(cooldown=1.0, clock=lambda: now[0])
|
||||
self.assertTrue(gate.try_enter())
|
||||
self.assertFalse(gate.try_enter())
|
||||
now[0] += 5
|
||||
self.assertFalse(gate.try_enter())
|
||||
gate.leave()
|
||||
now[0] += 0.5
|
||||
self.assertFalse(gate.try_enter())
|
||||
now[0] += 0.5
|
||||
self.assertTrue(gate.try_enter())
|
||||
|
||||
def test_zero_cooldown_only_blocks_while_busy(self):
|
||||
gate = file_reveal.RevealGate(cooldown=0)
|
||||
self.assertTrue(gate.try_enter())
|
||||
self.assertFalse(gate.try_enter())
|
||||
gate.leave()
|
||||
self.assertTrue(gate.try_enter())
|
||||
|
||||
|
||||
class TestRevealInFileManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.root = Path(os.path.realpath(temp.name)) / "out"
|
||||
self.path = self.root / "sub" / "a.png"
|
||||
self.path.parent.mkdir(parents=True)
|
||||
self.path.write_bytes(b"png")
|
||||
self.popen = patch.object(subprocess, "Popen").start()
|
||||
self.reaped = patch.object(file_reveal, "_reap").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def reveal(self, path=None):
|
||||
reveal_in_file_manager(self.path if path is None else path, self.root)
|
||||
|
||||
def which(self, available):
|
||||
def lookup(name, path=None):
|
||||
self.assertEqual(path, file_reveal.SYSTEM_PATH)
|
||||
return available.get(name)
|
||||
|
||||
return patch.object(file_reveal.shutil, "which", side_effect=lookup)
|
||||
|
||||
def argv(self, call=0):
|
||||
return self.popen.call_args_list[call].args[0]
|
||||
|
||||
def assert_detached(self, call=0):
|
||||
options = self.popen.call_args_list[call].kwargs
|
||||
for stream in ("stdin", "stdout", "stderr"):
|
||||
self.assertIs(options[stream], subprocess.DEVNULL)
|
||||
self.assertIs(options["close_fds"], True)
|
||||
self.assertIs(options["start_new_session"], True)
|
||||
self.assertNotIn("shell", options)
|
||||
self.assertEqual(options["env"]["PATH"], file_reveal.SYSTEM_PATH)
|
||||
|
||||
def windows(self, selected=True, startfile=None):
|
||||
"""Windows without Windows: the shell libraries and os.startfile
|
||||
injected; the patched shell records its calls."""
|
||||
shell = FakeShell(select=0 if selected else -2147467259)
|
||||
select = patch.object(
|
||||
file_reveal,
|
||||
"_windows_libraries",
|
||||
return_value=(shell.ole32, shell.shell32),
|
||||
)
|
||||
select.shell = shell
|
||||
start = patch.object(
|
||||
file_reveal.os, "startfile", startfile or MagicMock(), create=True
|
||||
)
|
||||
platform = patch.object(file_reveal.sys, "platform", "win32")
|
||||
return select, start, platform
|
||||
|
||||
def test_windows_selects_through_the_shell_api_without_a_process(self):
|
||||
# A comma path splits Explorer's own command line (security review H1);
|
||||
# the shell API takes the path as one value, unchanged.
|
||||
path = self.root / "a," / "b,c.png"
|
||||
path.parent.mkdir()
|
||||
path.write_bytes(b"png")
|
||||
select, start, platform = self.windows(selected=True)
|
||||
with select, start as startfile, platform:
|
||||
self.reveal(path)
|
||||
self.assertEqual(dict(select.shell.calls)["ILCreateFromPathW"], (str(path),))
|
||||
startfile.assert_not_called()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_windows_falls_back_to_opening_the_folder(self):
|
||||
select, start, platform = self.windows(selected=False)
|
||||
with select, start as startfile, platform:
|
||||
self.reveal()
|
||||
self.assertEqual(
|
||||
dict(select.shell.calls)["ILCreateFromPathW"], (str(self.path),)
|
||||
)
|
||||
startfile.assert_called_once_with(str(self.path.parent), "explore")
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_windows_folder_start_failure_raises(self):
|
||||
select, start, platform = self.windows(
|
||||
selected=False, startfile=MagicMock(side_effect=OSError("denied"))
|
||||
)
|
||||
with select, start, platform, self.assertRaises(OSError):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_windows_without_startfile_is_unavailable(self):
|
||||
with (
|
||||
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
|
||||
patch.object(file_reveal.sys, "platform", "win32"),
|
||||
patch.object(file_reveal.os, "startfile", None, create=True),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_macos_reveals_with_the_system_open(self):
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "darwin"),
|
||||
patch.object(file_reveal, "_macos_open", return_value="/usr/bin/open"),
|
||||
self.which({"open": "/planted/open"}),
|
||||
):
|
||||
self.reveal()
|
||||
self.assertEqual(self.argv(), ["/usr/bin/open", "-R", str(self.path)])
|
||||
self.assert_detached()
|
||||
self.popen.return_value.wait.assert_not_called()
|
||||
self.reaped.assert_called_once_with(self.popen.return_value)
|
||||
|
||||
def test_macos_open_is_the_fixed_system_path(self):
|
||||
self.assertEqual(file_reveal.MACOS_OPEN, "/usr/bin/open")
|
||||
with patch.object(file_reveal.os.path, "isfile", return_value=False):
|
||||
self.assertIsNone(file_reveal._macos_open())
|
||||
with (
|
||||
patch.object(file_reveal.os.path, "isfile", return_value=True),
|
||||
patch.object(file_reveal.os, "access", return_value=True),
|
||||
):
|
||||
self.assertEqual(file_reveal._macos_open(), "/usr/bin/open")
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "darwin"),
|
||||
patch.object(file_reveal, "_macos_open", return_value=None),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_relative_path_lookup_never_counts(self):
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which({"dbus-send": "./dbus-send", "xdg-open": "bin/xdg-open"}),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_posix_opener_lookup_does_not_depend_on_the_host_path_rules(self):
|
||||
# Python 3.13 on Windows no longer treats "/usr/bin/x" as absolute. The
|
||||
# Linux and macOS openers are POSIX paths whatever host runs the code,
|
||||
# so a host os.path with those rules must not reject them.
|
||||
windows_313 = SimpleNamespace(isabs=lambda value: False)
|
||||
with (
|
||||
patch.object(file_reveal.os, "path", windows_313),
|
||||
self.which({"xdg-open": "/usr/bin/xdg-open", "rel": "bin/rel"}),
|
||||
):
|
||||
self.assertEqual(file_reveal._executable("xdg-open"), "/usr/bin/xdg-open")
|
||||
self.assertIsNone(file_reveal._executable("rel"))
|
||||
|
||||
def test_children_get_a_minimal_environment(self):
|
||||
inherited = {
|
||||
"PATH": "/venv/bin:/usr/bin",
|
||||
"PYTHONPATH": "/comfy",
|
||||
"LD_LIBRARY_PATH": "/venv/lib",
|
||||
"VIRTUAL_ENV": "/venv",
|
||||
"CIVITAI_API_KEY": "secret",
|
||||
"HOME": "/home/u",
|
||||
"DISPLAY": ":0",
|
||||
"WAYLAND_DISPLAY": "wayland-0",
|
||||
"XDG_RUNTIME_DIR": "/run/user/1000",
|
||||
"XDG_CURRENT_DESKTOP": "KDE",
|
||||
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
|
||||
"LANG": "en_US.UTF-8",
|
||||
# What a desktop opener can need (final review m1): the X11
|
||||
# cookie under GDM/SDDM, the session and the user's mimeapps.
|
||||
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
|
||||
"DESKTOP_SESSION": "plasma",
|
||||
"XDG_SESSION_DESKTOP": "KDE",
|
||||
"XDG_CONFIG_HOME": "/home/u/.config",
|
||||
"XDG_DATA_DIRS": "/usr/share",
|
||||
}
|
||||
with patch.dict(os.environ, inherited, clear=True):
|
||||
env = file_reveal._child_env()
|
||||
self.assertEqual(
|
||||
env,
|
||||
{
|
||||
"PATH": file_reveal.SYSTEM_PATH,
|
||||
"HOME": "/home/u",
|
||||
"DISPLAY": ":0",
|
||||
"WAYLAND_DISPLAY": "wayland-0",
|
||||
"XDG_RUNTIME_DIR": "/run/user/1000",
|
||||
"XDG_CURRENT_DESKTOP": "KDE",
|
||||
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
|
||||
"LANG": "en_US.UTF-8",
|
||||
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
|
||||
"DESKTOP_SESSION": "plasma",
|
||||
"XDG_SESSION_DESKTOP": "KDE",
|
||||
"XDG_CONFIG_HOME": "/home/u/.config",
|
||||
"XDG_DATA_DIRS": "/usr/share",
|
||||
},
|
||||
)
|
||||
self.assertNotIn("/venv", file_reveal.SYSTEM_PATH)
|
||||
|
||||
def test_linux_asks_the_file_manager_service_and_waits_for_its_reply(self):
|
||||
self.popen.return_value.wait.return_value = 0
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which({"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/x"}),
|
||||
):
|
||||
self.reveal()
|
||||
self.assertEqual(
|
||||
self.argv(),
|
||||
[
|
||||
"/usr/bin/dbus-send",
|
||||
"--session",
|
||||
"--print-reply",
|
||||
"--reply-timeout=2000",
|
||||
"--dest=org.freedesktop.FileManager1",
|
||||
"--type=method_call",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
f"array:string:{self.path.as_uri()}",
|
||||
"string:",
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.popen.call_count, 1)
|
||||
self.popen.return_value.wait.assert_called_once_with(
|
||||
timeout=file_reveal.DBUS_WAIT
|
||||
)
|
||||
self.assert_detached()
|
||||
self.reaped.assert_not_called()
|
||||
|
||||
def test_linux_falls_back_to_xdg_open_on_the_folder(self):
|
||||
both = {"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/usr/bin/xdg-open"}
|
||||
cases = {
|
||||
"missing": ({"xdg-open": "/usr/bin/xdg-open"}, None),
|
||||
"no reply": (both, 1),
|
||||
"start error": (both, OSError("no bus")),
|
||||
"hung": (both, subprocess.TimeoutExpired("dbus-send", 3)),
|
||||
}
|
||||
for name, (available, outcome) in cases.items():
|
||||
with self.subTest(name):
|
||||
self.popen.reset_mock(side_effect=True, return_value=True)
|
||||
self.reaped.reset_mock()
|
||||
dbus = MagicMock()
|
||||
if isinstance(outcome, OSError):
|
||||
self.popen.side_effect = [outcome, MagicMock()]
|
||||
elif isinstance(outcome, subprocess.TimeoutExpired):
|
||||
dbus.wait.side_effect = [outcome, None]
|
||||
self.popen.side_effect = [dbus, MagicMock()]
|
||||
else:
|
||||
self.popen.return_value.wait.return_value = outcome
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which(available),
|
||||
):
|
||||
self.reveal()
|
||||
self.assertEqual(
|
||||
self.popen.call_args.args[0],
|
||||
["/usr/bin/xdg-open", str(self.path.parent)],
|
||||
)
|
||||
self.assert_detached(self.popen.call_count - 1)
|
||||
self.reaped.assert_called_once()
|
||||
if name == "hung":
|
||||
dbus.kill.assert_called_once_with()
|
||||
|
||||
def test_linux_without_any_opener_is_unavailable(self):
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which({}),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_relative_paths_are_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
reveal_in_file_manager(Path("sub/a.png"), self.root)
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_containment_is_rechecked_just_before_launching(self):
|
||||
# A parent swapped for a symlink after the route resolved the path
|
||||
# (security review L4) must not open a folder outside the root.
|
||||
outside = self.root.parent / "elsewhere"
|
||||
outside.mkdir()
|
||||
(outside / "a.png").write_bytes(b"png")
|
||||
(self.root / "sub" / "a.png").unlink()
|
||||
(self.root / "sub").rmdir()
|
||||
try:
|
||||
os.symlink(outside, self.root / "sub", target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("symlinks unavailable")
|
||||
missing = self.root / "gone.png"
|
||||
folder = self.root / "folder.png"
|
||||
folder.mkdir()
|
||||
for path in (self.path, missing, folder):
|
||||
for platform in ("linux", "darwin", "win32"):
|
||||
with (
|
||||
self.subTest(path=path.name, platform=platform),
|
||||
patch.object(file_reveal.sys, "platform", platform),
|
||||
patch.object(file_reveal, "_windows_libraries") as shell,
|
||||
self.assertRaises(file_reveal.RevealTargetChanged),
|
||||
):
|
||||
self.reveal(path)
|
||||
shell.assert_not_called()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_a_path_on_another_drive_is_not_contained(self):
|
||||
# Windows: commonpath raises ValueError across drives.
|
||||
with (
|
||||
patch.object(file_reveal.os.path, "commonpath", side_effect=ValueError),
|
||||
self.assertRaises(file_reveal.RevealTargetChanged),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
|
||||
class TestReaping(unittest.TestCase):
|
||||
def test_reap_waits_on_a_daemon_thread(self):
|
||||
process = MagicMock()
|
||||
with patch.object(file_reveal.threading, "Thread") as thread:
|
||||
file_reveal._reap(process)
|
||||
thread.assert_called_once()
|
||||
kwargs = thread.call_args.kwargs
|
||||
self.assertIs(kwargs["target"], process.wait)
|
||||
self.assertIs(kwargs["daemon"], True)
|
||||
thread.return_value.start.assert_called_once_with()
|
||||
|
||||
def test_reveal_runs_on_its_own_single_worker(self):
|
||||
executor = file_reveal.reveal_executor()
|
||||
self.assertIs(file_reveal.reveal_executor(), executor)
|
||||
self.assertEqual(executor._max_workers, 1)
|
||||
name = executor.submit(lambda: file_reveal.threading.current_thread().name)
|
||||
self.assertTrue(name.result(timeout=5).startswith("pm-reveal"))
|
||||
|
||||
|
||||
class FakeShell:
|
||||
"""ole32 and shell32 stand-ins recording the shell calls (H1)."""
|
||||
|
||||
def __init__(self, init=0, pidl=1234, select=0):
|
||||
self.calls = []
|
||||
self.ole32 = types.SimpleNamespace(
|
||||
CoInitializeEx=self.fn("CoInitializeEx", init),
|
||||
CoUninitialize=self.fn("CoUninitialize", None),
|
||||
)
|
||||
self.shell32 = types.SimpleNamespace(
|
||||
ILCreateFromPathW=self.fn("ILCreateFromPathW", pidl),
|
||||
SHOpenFolderAndSelectItems=self.fn("SHOpenFolderAndSelectItems", select),
|
||||
ILFree=self.fn("ILFree", None),
|
||||
)
|
||||
|
||||
def fn(self, name, result):
|
||||
def call(*args):
|
||||
self.calls.append((name, args))
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
return MagicMock(side_effect=call)
|
||||
|
||||
def names(self):
|
||||
return [name for name, _ in self.calls]
|
||||
|
||||
|
||||
class TestWindowsReveal(unittest.TestCase):
|
||||
"""The Windows opener with the shell libraries and os.startfile faked.
|
||||
The folder fallback is recorded in the same call list, so its place
|
||||
inside the COM bracket is visible (final review m2)."""
|
||||
|
||||
def reveal(self, shell, path=r"C:\out\a,\b.png"):
|
||||
def startfile(*args):
|
||||
shell.calls.append(("startfile", args))
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
file_reveal,
|
||||
"_windows_libraries",
|
||||
return_value=(shell.ole32, shell.shell32),
|
||||
),
|
||||
patch.object(
|
||||
file_reveal.os,
|
||||
"startfile",
|
||||
MagicMock(side_effect=startfile),
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
file_reveal._reveal_windows(Path(path))
|
||||
return shell
|
||||
|
||||
def test_selects_the_item_and_frees_everything(self):
|
||||
path = r"C:\out\a,\Windows\System32\calc.exe,b\x.png"
|
||||
shell = self.reveal(FakeShell(), path)
|
||||
self.assertEqual(
|
||||
shell.names(),
|
||||
[
|
||||
"CoInitializeEx",
|
||||
"ILCreateFromPathW",
|
||||
"SHOpenFolderAndSelectItems",
|
||||
"ILFree",
|
||||
"CoUninitialize",
|
||||
],
|
||||
)
|
||||
self.assertEqual(dict(shell.calls)["ILCreateFromPathW"], (str(Path(path)),))
|
||||
self.assertEqual(
|
||||
dict(shell.calls)["SHOpenFolderAndSelectItems"], (1234, 0, None, 0)
|
||||
)
|
||||
self.assertEqual(dict(shell.calls)["ILFree"], (1234,))
|
||||
|
||||
def test_declares_the_shell_signatures(self):
|
||||
shell = self.reveal(FakeShell())
|
||||
create = shell.shell32.ILCreateFromPathW
|
||||
self.assertEqual(create.argtypes, [ctypes.c_wchar_p])
|
||||
self.assertIs(create.restype, ctypes.c_void_p)
|
||||
open_select = shell.shell32.SHOpenFolderAndSelectItems
|
||||
self.assertEqual(
|
||||
open_select.argtypes,
|
||||
[ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulong],
|
||||
)
|
||||
self.assertIs(open_select.restype, ctypes.c_long)
|
||||
self.assertEqual(shell.shell32.ILFree.argtypes, [ctypes.c_void_p])
|
||||
self.assertEqual(
|
||||
shell.ole32.CoInitializeEx.argtypes, [ctypes.c_void_p, ctypes.c_ulong]
|
||||
)
|
||||
self.assertIs(shell.ole32.CoInitializeEx.restype, ctypes.c_long)
|
||||
|
||||
def test_already_initialized_com_is_used_and_left_alone(self):
|
||||
shell = self.reveal(FakeShell(init=file_reveal.RPC_E_CHANGED_MODE))
|
||||
self.assertIn("SHOpenFolderAndSelectItems", shell.names())
|
||||
self.assertNotIn("CoUninitialize", shell.names())
|
||||
self.assertNotIn("startfile", shell.names())
|
||||
shell = self.reveal(FakeShell(init=file_reveal.S_FALSE))
|
||||
self.assertNotIn("startfile", shell.names())
|
||||
self.assertEqual(shell.names()[-1], "CoUninitialize")
|
||||
|
||||
def test_the_folder_fallback_runs_inside_com(self):
|
||||
# ShellExecute callers should initialize COM first (final review m2).
|
||||
for name, shell in {
|
||||
"no pidl": FakeShell(pidl=None),
|
||||
"select failed": FakeShell(select=-2147467259),
|
||||
"raises": FakeShell(select=OSError("access violation")),
|
||||
}.items():
|
||||
with self.subTest(name):
|
||||
names = self.reveal(shell).names()
|
||||
self.assertEqual(names[0], "CoInitializeEx")
|
||||
self.assertEqual(names[-2:], ["startfile", "CoUninitialize"])
|
||||
if name != "no pidl":
|
||||
self.assertIn("ILFree", names)
|
||||
|
||||
def test_failed_com_still_opens_the_folder_without_balancing(self):
|
||||
for init in (-2147024882, OSError("no ole32 export")):
|
||||
with self.subTest(init=init):
|
||||
names = self.reveal(FakeShell(init=init)).names()
|
||||
self.assertEqual(names, ["CoInitializeEx", "startfile"])
|
||||
|
||||
def test_missing_libraries_open_the_folder(self):
|
||||
startfile = MagicMock()
|
||||
with (
|
||||
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
|
||||
patch.object(file_reveal.os, "startfile", startfile, create=True),
|
||||
):
|
||||
file_reveal._reveal_windows(Path(r"C:\out\a.png"))
|
||||
startfile.assert_called_once_with(str(Path(r"C:\out\a.png").parent), "explore")
|
||||
|
||||
def test_libraries_are_unavailable_off_windows(self):
|
||||
if sys.platform == "win32":
|
||||
self.skipTest("loads the real shell libraries on Windows")
|
||||
with self.assertRaises((AttributeError, OSError)):
|
||||
file_reveal._windows_libraries()
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "win32", "the real Windows shell libraries")
|
||||
class TestRealWindowsShell(unittest.TestCase):
|
||||
"""Final review I1: the declared signatures against the real ole32 and
|
||||
shell32, without SHOpenFolderAndSelectItems (no window opens on CI)."""
|
||||
|
||||
def setUp(self):
|
||||
self.ole32, self.shell32 = file_reveal._windows_libraries()
|
||||
file_reveal._declare_shell_signatures(self.ole32, self.shell32)
|
||||
|
||||
def test_a_pidl_is_created_for_a_real_file_and_freed(self):
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as folder:
|
||||
path = Path(folder) / "a,b.png"
|
||||
path.write_bytes(b"png") # write_bytes closes its handle
|
||||
pidl = self.shell32.ILCreateFromPathW(str(path))
|
||||
self.assertIsInstance(pidl, int)
|
||||
self.assertNotEqual(pidl, 0)
|
||||
self.shell32.ILFree(pidl)
|
||||
self.assertIsNone(self.shell32.ILCreateFromPathW(str(path) + ".gone"))
|
||||
path.unlink()
|
||||
|
||||
def probe(self):
|
||||
"""CoInitializeEx's answer on this thread, balanced at once."""
|
||||
result = self.ole32.CoInitializeEx(
|
||||
None,
|
||||
file_reveal.COINIT_APARTMENTTHREADED | file_reveal.COINIT_DISABLE_OLE1DDE,
|
||||
)
|
||||
if result in (file_reveal.S_OK, file_reveal.S_FALSE):
|
||||
self.ole32.CoUninitialize()
|
||||
return result
|
||||
|
||||
def test_com_is_entered_and_balanced_through_the_module_helper(self):
|
||||
before = self.probe()
|
||||
with file_reveal._com_apartment(self.ole32) as ready:
|
||||
self.assertIs(ready, True)
|
||||
# An unbalanced bracket would leave COM initialized: S_FALSE after S_OK.
|
||||
self.assertEqual(self.probe(), before)
|
||||
|
||||
|
||||
class TestRevealRoute(ImageAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
self.opened, self.roots, self.threads = [], [], []
|
||||
self.outcome = None
|
||||
|
||||
def opener(path, root):
|
||||
self.threads.append(file_reveal.threading.current_thread().name)
|
||||
if self.outcome is not None:
|
||||
raise self.outcome
|
||||
self.opened.append(path)
|
||||
self.roots.append(root)
|
||||
|
||||
self.api._reveal_opener = opener
|
||||
self.api._reveal_gate = file_reveal.RevealGate(cooldown=0)
|
||||
return app
|
||||
|
||||
def origin(self):
|
||||
return str(self.client.make_url("/").origin())
|
||||
|
||||
def scope(self, index=0):
|
||||
roots = self.api._get_all_output_dirs()
|
||||
return self.api._gallery_root_scope(roots[index], index)
|
||||
|
||||
async def post(self, body, **request_headers):
|
||||
# Like the workspace's fetch: a same-origin JSON POST.
|
||||
sent = {"Origin": self.origin(), **request_headers}
|
||||
sent = {key: value for key, value in sent.items() if value is not None}
|
||||
response = await self.client.post(REVEAL, json=body, headers=sent)
|
||||
return response.status, await response.json()
|
||||
|
||||
def body(self, path="sub/a.png", **changes):
|
||||
return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes}
|
||||
|
||||
async def test_loopback_request_opens_the_resolved_file(self):
|
||||
target = make_png(self.output_dir / "sub" / "a.png")
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual((status, data), (200, {"success": True}))
|
||||
self.assertEqual(self.opened, [Path(os.path.realpath(target))])
|
||||
self.assertEqual(self.roots, [self.output_dir])
|
||||
# Its own worker, never ComfyUI's shared default executor (L1).
|
||||
self.assertTrue(self.threads[0].startswith("pm-reveal"), self.threads)
|
||||
|
||||
async def test_target_changed_before_launch_is_404(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = file_reveal.RevealTargetChanged()
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual(data, {"success": False, "error": "Image file not found"})
|
||||
|
||||
async def test_non_loopback_remote_is_not_local(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
with patch.object(
|
||||
web.BaseRequest, "remote", new_callable=PropertyMock
|
||||
) as remote:
|
||||
remote.return_value = "192.168.1.20"
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 403)
|
||||
self.assertEqual(data["code"], "not-local")
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_loopback_with_a_proxy_header_is_not_local(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
for name in PROXY_HEADERS:
|
||||
with self.subTest(header=name):
|
||||
status, data = await self.post(self.body(), **{name: "127.0.0.1"})
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
status, data = await self.post(self.body(), Host="evil.example:8188")
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_missing_or_foreign_origin_is_not_local(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
port = self.client.make_url("/").port
|
||||
for origin in (None, f"http://127.0.0.1:{port + 1}", "null"):
|
||||
with self.subTest(origin=origin):
|
||||
status, data = await self.post(self.body(), Origin=origin)
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
status, data = await self.post(self.body(), **{"Sec-Fetch-Site": "same-site"})
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
status, _ = await self.post(self.body(), **{"Sec-Fetch-Site": "same-origin"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(self.opened), 1)
|
||||
|
||||
async def test_only_json_bodies_are_accepted(self):
|
||||
# text/plain would be a CORS "simple" request with no preflight (L2).
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
body = json.dumps(self.body())
|
||||
for content_type in ("text/plain", "application/x-www-form-urlencoded", None):
|
||||
with self.subTest(content_type=content_type):
|
||||
sent = {"Origin": self.origin()}
|
||||
if content_type:
|
||||
sent["Content-Type"] = content_type
|
||||
response = await self.client.post(
|
||||
REVEAL, data=body.encode(), headers=sent
|
||||
)
|
||||
self.assertEqual(response.status, 415)
|
||||
data = await response.json()
|
||||
self.assertEqual(
|
||||
(data["success"], data["code"]), (False, "unsupported-media-type")
|
||||
)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_a_reveal_in_progress_or_cooling_down_is_busy(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
gate = self.api._reveal_gate
|
||||
self.assertTrue(gate.try_enter())
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 429)
|
||||
self.assertEqual(data, {**data, "success": False, "code": "busy"})
|
||||
gate.leave()
|
||||
now = [50.0]
|
||||
self.api._reveal_gate = file_reveal.RevealGate(
|
||||
cooldown=1.0, clock=lambda: now[0]
|
||||
)
|
||||
statuses = [(await self.post(self.body()))[0] for _ in range(2)]
|
||||
now[0] += 1.0
|
||||
statuses.append((await self.post(self.body()))[0])
|
||||
self.assertEqual(statuses, [200, 429, 200])
|
||||
self.assertEqual(len(self.opened), 2)
|
||||
|
||||
async def test_a_failed_reveal_releases_the_gate(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = OSError("cannot start")
|
||||
status, _ = await self.post(self.body())
|
||||
self.assertEqual(status, 500)
|
||||
self.outcome = None
|
||||
status, _ = await self.post(self.body())
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
async def test_default_gate_is_one_per_process_with_a_cooldown(self):
|
||||
gate = type(self.api)._reveal_gate
|
||||
self.assertIsInstance(gate, file_reveal.RevealGate)
|
||||
self.assertEqual(gate.cooldown, file_reveal.REVEAL_COOLDOWN)
|
||||
self.assertGreaterEqual(file_reveal.REVEAL_COOLDOWN, 1.0)
|
||||
|
||||
async def test_resolution_errors_are_404_and_never_log_the_path(self):
|
||||
# ENAMETOOLONG and friends must not escape as a non-JSON 500 (L3).
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
secret = "sub/" + "n" * 300 + "/a.png"
|
||||
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
|
||||
self.api.logger.debug("start")
|
||||
status, data = await self.post(self.body(path=secret))
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual(data, {"success": False, "error": "Image file not found"})
|
||||
with patch.object(
|
||||
type(self.api),
|
||||
"_reveal_path",
|
||||
staticmethod(MagicMock(side_effect=OSError(36, "too long", secret))),
|
||||
):
|
||||
status, data = await self.post(self.body(path=secret))
|
||||
self.assertEqual(status, 404)
|
||||
self.assertIs(data["success"], False)
|
||||
for record in logs.records:
|
||||
if record.levelno >= logging.WARNING:
|
||||
self.assertNotIn("n" * 300, record.getMessage())
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_start_failure_never_logs_the_path_at_error_level(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = OSError(2, "No such file", str(self.output_dir / "sub"))
|
||||
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
|
||||
status, _ = await self.post(self.body())
|
||||
self.assertEqual(status, 500)
|
||||
errors = [r for r in logs.records if r.levelno >= logging.ERROR]
|
||||
self.assertTrue(errors)
|
||||
for record in errors:
|
||||
self.assertNotIn(str(self.output_dir), record.getMessage())
|
||||
self.assertIsNone(record.exc_info)
|
||||
|
||||
async def test_changed_or_missing_scope_is_rejected(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
status, data = await self.post(self.body(root_scope="0" * 64))
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
status, data = await self.post(self.body(root_index=3))
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
for scope in (None, "ABC", 7, "g" * 64):
|
||||
with self.subTest(scope=scope):
|
||||
status, _ = await self.post(self.body(root_scope=scope))
|
||||
self.assertEqual(status, 400)
|
||||
body = self.body()
|
||||
del body["root_scope"]
|
||||
status, _ = await self.post(body)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_body_types_are_strict(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
for changes in (
|
||||
{"root_index": True},
|
||||
{"root_index": "0"},
|
||||
{"root_index": -1},
|
||||
{"root_index": 0.0},
|
||||
{"path": 5},
|
||||
{"path": ""},
|
||||
{"path": "a" * 5000 + ".png"},
|
||||
{"path": "sub/a\0.png"},
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
status, data = await self.post(self.body(**changes))
|
||||
self.assertEqual(status, 400, data)
|
||||
response = await self.client.post(
|
||||
REVEAL,
|
||||
data="[1]",
|
||||
headers={"Origin": self.origin(), "Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_traversal_and_absolute_paths_are_rejected(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
for path in (
|
||||
"../outside.png",
|
||||
"sub/../../outside.png",
|
||||
"sub\\..\\..\\outside.png",
|
||||
str(outside),
|
||||
"/" + outside.name,
|
||||
"C:/outside.png",
|
||||
"C:outside.png",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
status, data = await self.post(self.body(path=path))
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_symlink_out_of_the_root_is_refused(self):
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
self._make_symlink(outside, self.output_dir / "link.png")
|
||||
status, _ = await self.post(self.body(path="link.png"))
|
||||
self.assertIn(status, (403, 404))
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_missing_non_media_and_non_regular_files(self):
|
||||
(self.output_dir / "folder.png").mkdir()
|
||||
(self.output_dir / "notes.txt").write_text("x")
|
||||
for path, expected in (
|
||||
("sub/missing.png", 404),
|
||||
("folder.png", 404),
|
||||
("notes.txt", 403),
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
status, data = await self.post(self.body(path=path))
|
||||
self.assertEqual(status, expected, data)
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_unavailable_opener_is_501_and_start_failure_500(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = OpenerUnavailable()
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 501)
|
||||
self.assertEqual(data, {**data, "success": False, "code": "unavailable"})
|
||||
self.outcome = OSError("cannot start")
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 500)
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertNotIn("cannot start", data["error"])
|
||||
|
||||
async def test_default_opener_is_the_platform_file_manager(self):
|
||||
self.assertIs(type(self.api)._reveal_opener, file_reveal.reveal_in_file_manager)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -36,7 +36,8 @@ LIBRARY = {
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
NODE_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ def encode(read, *, prompts=None, file=None, library=None):
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -663,6 +664,21 @@ class TestInspectionResponse(unittest.TestCase):
|
||||
for key in ("positive_id", "pair_id", "category", "title"):
|
||||
self.assertNotIn(key, result["library"])
|
||||
self.assertEqual(result["library"]["tags"], ["valid"])
|
||||
# An unknown or non-string scope is dropped, never passed through.
|
||||
for bad in ("recipe", 3, ["pair"]):
|
||||
result = json.loads(
|
||||
encode(
|
||||
None,
|
||||
library={
|
||||
"status": "linked",
|
||||
"category_scope": bad,
|
||||
"tags_scope": bad,
|
||||
"tags": [],
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertNotIn("category_scope", result["library"])
|
||||
self.assertNotIn("tags_scope", result["library"])
|
||||
result = json.loads(
|
||||
encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]})
|
||||
)
|
||||
|
||||
@@ -92,6 +92,17 @@ class TestLibrary(unittest.TestCase):
|
||||
self.db.list_library_images(positive, pair_id=unrelated)
|
||||
self.assertIsNone(self.db.get_library_positive(9999))
|
||||
|
||||
def test_detail_variants_carry_their_pair_run_count(self):
|
||||
counted = self.db.save_prompt_pair("Cat", "Blur")
|
||||
fresh = self.db.save_prompt_pair("Cat", "Noise")
|
||||
self.db.record_prompt_pair_use(counted, 3)
|
||||
positive = self.db.get_prompt_pair(counted)["positive_prompt_id"]
|
||||
variants = {
|
||||
row["id"]: row for row in self.db.get_library_positive(positive)["variants"]
|
||||
}
|
||||
self.assertEqual(variants[counted]["run_count"], 3)
|
||||
self.assertEqual(variants[fresh]["run_count"], 0)
|
||||
|
||||
def test_options_include_active_pair_categories_not_negative_only_or_archived(self):
|
||||
self.save("Legacy", category="Legacy category")
|
||||
pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category")
|
||||
|
||||
Vendored
+11
-10
@@ -73,18 +73,21 @@ test('overlay rules on concept selectors carry a decision marker', () => {
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
// PRs C and D lint each page stylesheet they keep: a page rule on a concept selector is an override like the overlay's.
|
||||
// Each kept page stylesheet is linted: a page rule on a concept selector is an override like the overlay's.
|
||||
test('kept page stylesheets mark their concept-selector overrides', () => {
|
||||
for (const name of ['images.css', 'library.css', 'inspector.css']) {
|
||||
for (const name of ['library.css']) {
|
||||
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(join(root, 'web/js/workspace', name), 'utf8'));
|
||||
assert.deepEqual(violations, [], name);
|
||||
}
|
||||
});
|
||||
|
||||
// #249 C8: the Images page renders the concept DOM, so the replaced production rules stay retired.
|
||||
test('images.css keeps no retired Images page rules', () => {
|
||||
const css = readFileSync(join(root, 'web/js/workspace/images.css'), 'utf8');
|
||||
for (const retired of ['.image-facts', '.image-card', '.image-grid', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation']) {
|
||||
// #249 C8 and D3: the Images page and the inspector render the concept DOM, so the replaced production rules stay
|
||||
// retired; the few production-only rules still consumed live in the overlay.
|
||||
test('the overlay keeps no retired Images page or inspector rules', () => {
|
||||
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
|
||||
// `.image-grid` and `.image-card-actions` are concept selectors now, so only the retired production names are listed.
|
||||
for (const retired of ['.image-facts', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation',
|
||||
'.image-details', '.file-cover', '.file-inspector-inner', '.file-title', '.file-chip', '.file-facts', '.generation-facts', '.file-inspector-footer']) {
|
||||
assert.equal(css.includes(retired), false, `${retired} is retired`);
|
||||
}
|
||||
});
|
||||
@@ -106,15 +109,13 @@ test('workspace.html loads controls, viewer, concept, page CSS and the overlay i
|
||||
'js/shared/toolkit/controls.css',
|
||||
'lib/viewerjs/viewer.min.css',
|
||||
'js/workspace/concept.css',
|
||||
'js/workspace/images.css',
|
||||
'js/workspace/library.css',
|
||||
'js/workspace/inspector.css',
|
||||
'js/workspace/workspace.css',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the retired shell, vocabulary and editor stylesheets stay deleted', () => {
|
||||
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css']) {
|
||||
test('the retired shell, vocabulary, editor, Images and inspector stylesheets stay deleted', () => {
|
||||
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css']) {
|
||||
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
|
||||
}
|
||||
});
|
||||
|
||||
+18
@@ -197,6 +197,24 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-category.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-export.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-selection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Show one gallery file in this computer's file manager (TESTING.md D38).
|
||||
|
||||
The route that calls this is local-only (``is_local_request``) and
|
||||
single-flight (``RevealGate``), and hands in a path it has already resolved
|
||||
inside a gallery root, which is re-checked just before launching. Windows
|
||||
goes through the shell API (no command line exists to parse); macOS and Linux
|
||||
openers run as an argument list with an absolute executable from a fixed
|
||||
system PATH, no shell, no inherited streams and a minimal environment, so
|
||||
neither a file name nor ``PATH`` can change what runs or what it parses.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
|
||||
# Only POSIX argv launches of a fixed opener (see _launch); Windows never
|
||||
# starts a process (see _select_item).
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# A local reverse proxy makes every client look like loopback, so a request
|
||||
# that carries any forwarding-style header counts as remote: these names, and
|
||||
# every X-Forwarded-*, X-Original-* and *-Client-IP header.
|
||||
FORWARDING_HEADERS = frozenset(
|
||||
(
|
||||
"forwarded",
|
||||
"via",
|
||||
"x-real-ip",
|
||||
"cf-connecting-ip",
|
||||
"true-client-ip",
|
||||
"x-client-ip",
|
||||
"x-cluster-client-ip",
|
||||
"fastly-client-ip",
|
||||
)
|
||||
)
|
||||
FORWARDING_PREFIXES = ("x-forwarded-", "x-original-")
|
||||
FORWARDING_SUFFIXES = ("-client-ip",)
|
||||
DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
# One reveal at a time per process, then a pause before the next.
|
||||
REVEAL_COOLDOWN = 1.0
|
||||
# dbus-send waits this long for the FileManager1 reply, so its exit status
|
||||
# says whether a file manager really took the request.
|
||||
DBUS_REPLY_TIMEOUT_MS = 2000
|
||||
# The dbus-send process itself gets a little longer before it is killed.
|
||||
DBUS_WAIT = 3.0
|
||||
FILE_MANAGER_DEST = "org.freedesktop.FileManager1"
|
||||
# Openers are looked up here only, never on ComfyUI's (venv) PATH, and the
|
||||
# children get this as their PATH.
|
||||
SYSTEM_PATH = os.pathsep.join(
|
||||
("/usr/local/bin", "/usr/bin", "/bin", "/run/current-system/sw/bin")
|
||||
)
|
||||
MACOS_OPEN = "/usr/bin/open"
|
||||
# What a desktop opener needs from ComfyUI's environment; everything else
|
||||
# (venv PATH, PYTHONPATH, LD_LIBRARY_PATH, API keys) stays behind.
|
||||
CHILD_ENV_NAMES = (
|
||||
"HOME",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_SESSION_TYPE",
|
||||
"XDG_SESSION_DESKTOP",
|
||||
"DESKTOP_SESSION",
|
||||
"XDG_DATA_DIRS",
|
||||
"XDG_CONFIG_DIRS",
|
||||
"XDG_CONFIG_HOME",
|
||||
# The X11 cookie: a non-default path under GDM or SDDM.
|
||||
"XAUTHORITY",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
)
|
||||
|
||||
|
||||
class OpenerUnavailable(Exception):
|
||||
"""No file manager can be started on this computer."""
|
||||
|
||||
|
||||
class RevealTargetChanged(Exception):
|
||||
"""The file is no longer a regular file inside its root."""
|
||||
|
||||
|
||||
_executor_lock = threading.Lock()
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
|
||||
|
||||
def reveal_executor() -> ThreadPoolExecutor:
|
||||
"""The one worker that runs openers, so a slow file manager or dbus reply
|
||||
never holds a thread of ComfyUI's shared default executor."""
|
||||
global _executor
|
||||
with _executor_lock:
|
||||
if _executor is None:
|
||||
_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="pm-reveal"
|
||||
)
|
||||
return _executor
|
||||
|
||||
|
||||
def _loopback_address(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return False
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
return (mapped or address).is_loopback
|
||||
|
||||
|
||||
def _loopback_host(host: str) -> bool:
|
||||
"""True for a Host name naming this computer.
|
||||
|
||||
Rejects every other name, so a DNS-rebinding page whose name now resolves
|
||||
to 127.0.0.1 still reads as remote.
|
||||
"""
|
||||
host = host.rstrip(".").lower()
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
return True
|
||||
return _loopback_address(host.strip("[]"))
|
||||
|
||||
|
||||
def _authority(authority: str) -> tuple[str, int | None] | None:
|
||||
"""``(host, port)`` of a Host value, or None when it does not parse."""
|
||||
try:
|
||||
parts = urlsplit(f"//{authority}")
|
||||
host, port = parts.hostname, parts.port
|
||||
except ValueError:
|
||||
return None
|
||||
return (host, port) if host else None
|
||||
|
||||
|
||||
def _forwarded(headers: Mapping) -> bool:
|
||||
for name in headers.keys():
|
||||
name = name.lower()
|
||||
if (
|
||||
name in FORWARDING_HEADERS
|
||||
or name.startswith(FORWARDING_PREFIXES)
|
||||
or name.endswith(FORWARDING_SUFFIXES)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _same_origin(origin: str, scheme: str, host: tuple[str, int | None]) -> bool:
|
||||
"""True when *origin* is exactly this request's scheme://host:port."""
|
||||
default = DEFAULT_PORTS.get(scheme)
|
||||
if default is None:
|
||||
return False
|
||||
try:
|
||||
parts = urlsplit(origin)
|
||||
origin_host, origin_port = parts.hostname, parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
if (
|
||||
parts.scheme != scheme
|
||||
or not origin_host
|
||||
or parts.username is not None
|
||||
or parts.password is not None
|
||||
or parts.path
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
):
|
||||
return False
|
||||
host_name, host_port = host
|
||||
return (origin_host.rstrip("."), origin_port or default) == (
|
||||
host_name.rstrip("."),
|
||||
host_port or default,
|
||||
)
|
||||
|
||||
|
||||
def is_local_request(remote: str | None, headers: Mapping, scheme="http") -> bool:
|
||||
"""True only for a same-origin browser request from this computer.
|
||||
|
||||
That is: no forwarding-style header, a loopback client, a loopback name
|
||||
in Host, an ``Origin`` that is exactly this request's scheme://host:port
|
||||
(browsers send it on every fetch POST, so a missing one is refused), and
|
||||
``Sec-Fetch-Site: same-origin`` whenever that header is sent.
|
||||
"""
|
||||
if _forwarded(headers):
|
||||
return False
|
||||
if not _loopback_address(remote):
|
||||
return False
|
||||
host = _authority(headers.get("Host", ""))
|
||||
if host is None or not _loopback_host(host[0]):
|
||||
return False
|
||||
origin = headers.get("Origin")
|
||||
if not origin or not _same_origin(origin, scheme, host):
|
||||
return False
|
||||
site = headers.get("Sec-Fetch-Site")
|
||||
return site is None or site == "same-origin"
|
||||
|
||||
|
||||
class RevealGate:
|
||||
"""Single flight plus a cooldown: at most one reveal runs per process,
|
||||
and the next may start only *cooldown* seconds after it finished."""
|
||||
|
||||
def __init__(self, cooldown: float = REVEAL_COOLDOWN, clock=time.monotonic):
|
||||
self.cooldown = cooldown
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._busy = False
|
||||
self._finished: float | None = None
|
||||
|
||||
def try_enter(self) -> bool:
|
||||
with self._lock:
|
||||
if self._busy:
|
||||
return False
|
||||
if (
|
||||
self._finished is not None
|
||||
and self._clock() - self._finished < self.cooldown
|
||||
):
|
||||
return False
|
||||
self._busy = True
|
||||
return True
|
||||
|
||||
def leave(self) -> None:
|
||||
with self._lock:
|
||||
self._busy = False
|
||||
self._finished = self._clock()
|
||||
|
||||
|
||||
def _executable(name: str) -> str | None:
|
||||
"""Absolute path of *name* on the fixed SYSTEM_PATH, never a relative hit.
|
||||
|
||||
Only the Linux and macOS openers are looked up here, so absoluteness follows
|
||||
POSIX rules rather than the host's (Python 3.13 on Windows no longer counts
|
||||
"/usr/bin/x" as absolute).
|
||||
"""
|
||||
found = shutil.which(name, path=SYSTEM_PATH)
|
||||
return found if found and posixpath.isabs(found) else None
|
||||
|
||||
|
||||
def _macos_open() -> str | None:
|
||||
"""macOS's own ``open``, at its fixed system path."""
|
||||
if os.path.isfile(MACOS_OPEN) and os.access(MACOS_OPEN, os.X_OK):
|
||||
return MACOS_OPEN
|
||||
return None
|
||||
|
||||
|
||||
def _child_env() -> dict[str, str]:
|
||||
env = {name: os.environ[name] for name in CHILD_ENV_NAMES if name in os.environ}
|
||||
return {"PATH": SYSTEM_PATH, **env}
|
||||
|
||||
|
||||
def _reap(process: subprocess.Popen) -> None:
|
||||
"""Wait for *process* on a daemon thread, so it never lingers as a zombie
|
||||
and is never waited on by the caller."""
|
||||
threading.Thread(target=process.wait, name="pm-reveal-reaper", daemon=True).start()
|
||||
|
||||
|
||||
def _contained_file(path: Path, root: Path) -> Path:
|
||||
"""*path*'s real path when it is still a regular file inside *root*.
|
||||
|
||||
Run right before launching: a parent swapped for a symlink since the
|
||||
route resolved the path must not open a folder outside the root.
|
||||
"""
|
||||
real = os.path.realpath(path)
|
||||
real_root = os.path.realpath(root)
|
||||
try:
|
||||
inside = os.path.commonpath(
|
||||
[os.path.normcase(real), os.path.normcase(real_root)]
|
||||
) == os.path.normcase(real_root)
|
||||
except ValueError:
|
||||
inside = False
|
||||
if not inside or real == real_root or not os.path.isfile(real):
|
||||
raise RevealTargetChanged()
|
||||
return Path(real)
|
||||
|
||||
|
||||
# HRESULTs as the signed 32-bit values ctypes.c_long returns.
|
||||
S_OK = 0
|
||||
S_FALSE = 1
|
||||
RPC_E_CHANGED_MODE = -2147417850 # 0x80010106: COM already set up otherwise
|
||||
COINIT_APARTMENTTHREADED = 0x2
|
||||
COINIT_DISABLE_OLE1DDE = 0x4
|
||||
|
||||
|
||||
def _windows_libraries():
|
||||
"""Private ole32 and shell32 handles, so the signatures set here never
|
||||
change another library's view of ``ctypes.windll``."""
|
||||
import ctypes
|
||||
|
||||
return ctypes.WinDLL("ole32"), ctypes.WinDLL("shell32")
|
||||
|
||||
|
||||
def _declare_shell_signatures(ole32, shell32) -> None:
|
||||
import ctypes
|
||||
|
||||
ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
ole32.CoInitializeEx.restype = ctypes.c_long
|
||||
ole32.CoUninitialize.argtypes = []
|
||||
ole32.CoUninitialize.restype = None
|
||||
shell32.ILCreateFromPathW.argtypes = [ctypes.c_wchar_p]
|
||||
shell32.ILCreateFromPathW.restype = ctypes.c_void_p
|
||||
shell32.SHOpenFolderAndSelectItems.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
shell32.SHOpenFolderAndSelectItems.restype = ctypes.c_long
|
||||
shell32.ILFree.argtypes = [ctypes.c_void_p]
|
||||
shell32.ILFree.restype = None
|
||||
|
||||
|
||||
def _shell_libraries():
|
||||
"""The declared ole32 and shell32, or None where they cannot be loaded."""
|
||||
try:
|
||||
ole32, shell32 = _windows_libraries()
|
||||
_declare_shell_signatures(ole32, shell32)
|
||||
except Exception: # no shell libraries: the folder fallback still runs
|
||||
return None
|
||||
return ole32, shell32
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _com_apartment(ole32):
|
||||
"""The calling thread inside COM for the block; yields whether COM is
|
||||
usable. Only a successful CoInitializeEx (S_OK or S_FALSE) is balanced,
|
||||
so the persistent reveal thread never drifts."""
|
||||
try:
|
||||
result = ole32.CoInitializeEx(
|
||||
None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE
|
||||
)
|
||||
except Exception: # an unusable ole32 leaves COM alone
|
||||
result = None
|
||||
try:
|
||||
yield result in (S_OK, S_FALSE, RPC_E_CHANGED_MODE)
|
||||
finally:
|
||||
if result in (S_OK, S_FALSE):
|
||||
ole32.CoUninitialize()
|
||||
|
||||
|
||||
def _select_item(shell32, path: Path) -> bool:
|
||||
"""Open Explorer on *path*'s folder with *path* selected, via the shell
|
||||
API. The path is one wide-string value, so no command line is ever
|
||||
parsed (Explorer splits its own on commas). False on any failure; never
|
||||
raises into the route."""
|
||||
try:
|
||||
pidl = shell32.ILCreateFromPathW(str(path))
|
||||
if not pidl:
|
||||
return False
|
||||
try:
|
||||
return shell32.SHOpenFolderAndSelectItems(pidl, 0, None, 0) >= 0
|
||||
finally:
|
||||
shell32.ILFree(pidl)
|
||||
except Exception: # any shell failure falls back to the folder
|
||||
return False
|
||||
|
||||
|
||||
def _explore(folder: Path) -> None:
|
||||
startfile = getattr(os, "startfile", None)
|
||||
if startfile is None:
|
||||
raise OpenerUnavailable()
|
||||
# ShellExecute on the folder itself: no command line is built or parsed.
|
||||
startfile(str(folder), "explore")
|
||||
|
||||
|
||||
def _reveal_windows(path: Path) -> None:
|
||||
libraries = _shell_libraries()
|
||||
if libraries is None:
|
||||
_explore(path.parent)
|
||||
return
|
||||
ole32, shell32 = libraries
|
||||
with _com_apartment(ole32) as ready:
|
||||
if ready and _select_item(shell32, path):
|
||||
return
|
||||
# ShellExecute inside the same apartment: shell extensions may need COM.
|
||||
_explore(path.parent)
|
||||
|
||||
|
||||
def _launch(argv: list[str]) -> subprocess.Popen:
|
||||
options = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"close_fds": True,
|
||||
# Its own session, so stopping ComfyUI does not take the window along.
|
||||
"start_new_session": True,
|
||||
"env": _child_env(),
|
||||
}
|
||||
# POSIX only (Windows uses the shell API): argv[0] is an absolute opener,
|
||||
# the rest are fixed flags and one absolute path inside a gallery root,
|
||||
# passed to execve as separate strings with no shell to re-parse them.
|
||||
return subprocess.Popen(argv, **options) # nosec B603
|
||||
|
||||
|
||||
def _show_items(path: Path) -> bool:
|
||||
"""Ask the freedesktop FileManager1 service to select *path*; True only
|
||||
when the service replied (``--print-reply`` makes the exit status real)."""
|
||||
dbus = _executable("dbus-send")
|
||||
if dbus is None:
|
||||
return False
|
||||
argv = [
|
||||
dbus,
|
||||
"--session",
|
||||
"--print-reply",
|
||||
f"--reply-timeout={DBUS_REPLY_TIMEOUT_MS}",
|
||||
f"--dest={FILE_MANAGER_DEST}",
|
||||
"--type=method_call",
|
||||
"/org/freedesktop/FileManager1",
|
||||
f"{FILE_MANAGER_DEST}.ShowItems",
|
||||
f"array:string:{path.as_uri()}",
|
||||
"string:",
|
||||
]
|
||||
try:
|
||||
process = _launch(argv)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
return process.wait(timeout=DBUS_WAIT) == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
return False
|
||||
|
||||
|
||||
def reveal_in_file_manager(path: Path, root: Path) -> None:
|
||||
"""Open the file manager at *path*, a regular file inside *root*.
|
||||
|
||||
Re-checks that containment first (RevealTargetChanged). Raises
|
||||
OpenerUnavailable when no opener exists and OSError when the one found
|
||||
cannot start. Never waits on the file manager itself.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.is_absolute():
|
||||
raise ValueError("path must be absolute")
|
||||
path = _contained_file(path, Path(root))
|
||||
if sys.platform == "win32":
|
||||
_reveal_windows(path)
|
||||
return
|
||||
if sys.platform == "darwin":
|
||||
opener = _macos_open()
|
||||
if opener is None:
|
||||
raise OpenerUnavailable()
|
||||
_reap(_launch([opener, "-R", str(path)]))
|
||||
return
|
||||
if _show_items(path):
|
||||
return
|
||||
opener = _executable("xdg-open")
|
||||
if opener is None:
|
||||
raise OpenerUnavailable()
|
||||
_reap(_launch([opener, str(path.parent)]))
|
||||
@@ -289,6 +289,39 @@ def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _blank(value: str | bytes | None) -> bool:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", "replace")
|
||||
return value is None or not value.strip()
|
||||
|
||||
|
||||
def library_metadata(
|
||||
positive_category: str | bytes | None,
|
||||
positive_tags: list,
|
||||
recipe: tuple | None = None,
|
||||
) -> dict:
|
||||
"""The panel's saved category and tags, each with the scope it came from.
|
||||
|
||||
D39 (maintainer ruling, 2026-10-21): a recipe-linked image shows the recipe's
|
||||
own category and tags when set; otherwise each field separately falls back to
|
||||
the linked positive prompt's, the values Set category writes and the Images
|
||||
filters match. Tag sets are never merged. ``recipe`` is (category, tags).
|
||||
"""
|
||||
category, category_scope = positive_category, "positive"
|
||||
tags, tags_scope = positive_tags, "positive"
|
||||
if recipe is not None:
|
||||
if not _blank(recipe[0]):
|
||||
category, category_scope = recipe[0], "pair"
|
||||
if recipe[1]:
|
||||
tags, tags_scope = recipe[1], "pair"
|
||||
return {
|
||||
"category": category,
|
||||
"category_scope": category_scope,
|
||||
"tags": tags,
|
||||
"tags_scope": tags_scope,
|
||||
}
|
||||
|
||||
|
||||
def _library_projection(library: dict) -> dict:
|
||||
status = library.get("status")
|
||||
result = {
|
||||
@@ -307,9 +340,10 @@ def _library_projection(library: dict) -> dict:
|
||||
value = library.get(key)
|
||||
if value is None or isinstance(value, str):
|
||||
result[key] = value
|
||||
scope = library.get("metadata_scope")
|
||||
if scope in ("pair", "positive", None):
|
||||
result["metadata_scope"] = scope
|
||||
for key in ("category_scope", "tags_scope"):
|
||||
scope = library.get(key)
|
||||
if scope in ("pair", "positive", None):
|
||||
result[key] = scope
|
||||
tags = library.get("tags")
|
||||
result["tags"] = (
|
||||
[tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else []
|
||||
@@ -323,7 +357,8 @@ def _library_projection(library: dict) -> dict:
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
+222
-109
@@ -3,6 +3,8 @@ import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createSendButton } from './prompt-sender.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
|
||||
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
|
||||
@@ -15,22 +17,26 @@ const REPRESENTATIONS = {
|
||||
limited: 'Record exceeds inspection limits; text is unavailable',
|
||||
};
|
||||
const TEXTLESS = new Set(['invalid', 'limited']);
|
||||
const REVEAL_URL = '/prompt_manager/images/reveal';
|
||||
// D38 Show in folder: the route's answers, in the maintainer's words.
|
||||
const REVEAL_OPENED = 'Opened the folder on this computer.';
|
||||
const REVEAL_NOT_LOCAL = 'Show in folder works only in a browser on the computer running ComfyUI.';
|
||||
const REVEAL_UNAVAILABLE = 'No file manager is available on this computer.';
|
||||
const REVEAL_BUSY = 'The folder is already opening.';
|
||||
const REVEAL_FAILED = 'The folder could not be opened. Try again.';
|
||||
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
|
||||
const FACT_SOURCES = {'api-graph': 'Embedded API configuration', workflow: 'Saved workflow configuration', 'embedded-parameters': 'Embedded generation parameters'};
|
||||
|
||||
function safeFilename(value) {
|
||||
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
|
||||
}
|
||||
|
||||
/** Export only the bounded public evidence, never normalized graphs or container carriers. */
|
||||
export function inspectionDownloads(record) {
|
||||
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
|
||||
const stem = name.replace(/\.[^.]+$/, '') || 'file';
|
||||
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
|
||||
/**
|
||||
* One file's metadata envelope: only the bounded public evidence, never normalized graphs or container carriers.
|
||||
* Null when the inspection holds no full metadata. Shared by "Download metadata JSON" and the Images bulk export.
|
||||
*/
|
||||
export function metadataEnvelope(record) {
|
||||
const metadata = record?.metadata;
|
||||
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
|
||||
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
|
||||
if (!metadata || metadata.status === 'limited') return result;
|
||||
if (!metadata || metadata.status === 'limited') return null;
|
||||
const raw = {}, record_formats = {}, representations = {};
|
||||
for (const key of RAW_KEYS) {
|
||||
if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) {
|
||||
@@ -39,10 +45,23 @@ export function inspectionDownloads(record) {
|
||||
if (!TEXTLESS.has(record_formats[key]) && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key];
|
||||
}
|
||||
}
|
||||
const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
|
||||
return {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
|
||||
generation:record.generation, library:record.library,
|
||||
metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations,
|
||||
scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}};
|
||||
}
|
||||
|
||||
/** The inspector's metadata and workflow downloads, each with its filename or the reason it is unavailable. */
|
||||
export function inspectionDownloads(record) {
|
||||
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
|
||||
const stem = name.replace(/\.[^.]+$/, '') || 'file';
|
||||
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
|
||||
const metadata = record?.metadata;
|
||||
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
|
||||
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
|
||||
const envelope = metadataEnvelope(record);
|
||||
if (!envelope) return result;
|
||||
const {raw, record_formats} = envelope.metadata;
|
||||
result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)};
|
||||
if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW;
|
||||
else if (record_formats.workflow === 'limited') result.workflow.reason = LIMITED_WORKFLOW;
|
||||
@@ -52,6 +71,23 @@ export function inspectionDownloads(record) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The reveal body for a listed file's identity (`root_index`, `root_scope`, `path`), else null (D38). */
|
||||
export function revealTarget(identity) {
|
||||
if (identity?.kind !== 'rooted-file') return null;
|
||||
const {root_index, root_scope, path} = identity;
|
||||
if (!Number.isInteger(root_index) || root_index < 0 || typeof root_scope !== 'string' || !/^[0-9a-f]{64}$/.test(root_scope) ||
|
||||
typeof path !== 'string' || !path) return null;
|
||||
return {root_index, root_scope, path};
|
||||
}
|
||||
/** The toast for a reveal outcome: null is success, otherwise the request's error (D38). */
|
||||
export function revealOutcome(error) {
|
||||
if (error === null) return REVEAL_OPENED;
|
||||
if (error?.status === 403 && error.code === 'not-local') return REVEAL_NOT_LOCAL;
|
||||
if (error?.status === 501) return REVEAL_UNAVAILABLE;
|
||||
if (error?.status === 429 && error.code === 'busy') return REVEAL_BUSY;
|
||||
return REVEAL_FAILED;
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -71,7 +107,8 @@ function disclosure(label) {
|
||||
function definitionList(entries, className) {
|
||||
const list = el('dl', undefined, className);
|
||||
for (const [label, value] of entries) {
|
||||
const row = el('div'); row.append(el('dt', label), el('dd', value ?? 'Not available')); list.append(row);
|
||||
// A value is its text, or a prepared `dd` (one a caller keeps to add to, such as D38's Path).
|
||||
const row = el('div'); row.append(el('dt', label), typeof value === 'object' && value !== null ? value : el('dd', value ?? 'Not available')); list.append(row);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -85,7 +122,41 @@ function formatBytes(value) {
|
||||
return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
|
||||
}
|
||||
|
||||
export function mountFileInspector(container, {sender, parentScope, inline = false, promptContext = '', onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
|
||||
/** The six facts, and the limit note their values point to when the configuration exceeded inspection limits. */
|
||||
function generationFacts(record, listClass, noteClass) {
|
||||
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), listClass);
|
||||
if (!FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) return [facts];
|
||||
const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', noteClass);
|
||||
explanation.id = `file-fact-limit-${++factReasonId}`;
|
||||
FACTS.forEach(([key], index) => {
|
||||
if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id);
|
||||
});
|
||||
return [facts, explanation];
|
||||
}
|
||||
// D21: the panel badge names the strongest resolved prompt source; any other source shows no badge.
|
||||
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
|
||||
function evidenceBadge(prompts) {
|
||||
const sources = new Set(Object.values(prompts).filter(role => role?.status === 'resolved').map(role => role.source));
|
||||
return EVIDENCE_BADGES.find(([source]) => sources.has(source))?.[1] ?? null;
|
||||
}
|
||||
/** The file's extension without its leading dot, upper case ("PNG"). */
|
||||
function extensionOf(record) {
|
||||
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
|
||||
}
|
||||
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
|
||||
function prefixedChip(prefix, text) {
|
||||
const chip = el('span', undefined, 'chip');
|
||||
chip.append(el('span', prefix, 'ca-visually-hidden'), text);
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's inspector. `variant: 'panel'` is the Images side panel in the concept's structure (app.js imageDetails
|
||||
* a400–414, D20): cover, heading, filename, D21 badges, prompt blocks, tag chips (`onTagFilter`), facts and footer.
|
||||
* `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File &
|
||||
* provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`.
|
||||
*/
|
||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}}) {
|
||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
let contentScope;
|
||||
@@ -96,121 +167,174 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
scope.listen(container, 'wheel', onInteraction, {passive:true});
|
||||
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
|
||||
|
||||
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
|
||||
function statusView(state, parent) {
|
||||
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'small');
|
||||
status.setAttribute('role', state.error ? 'alert' : 'status'); parent.append(status);
|
||||
if (state.error && state.refreshRequired) {
|
||||
if (onRefresh) {
|
||||
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
|
||||
contentScope.listen(refresh, 'click', onRefresh); parent.append(refresh);
|
||||
} else parent.append(el('p', 'Reopen this file from a refreshed image collection.', 'small'));
|
||||
} else if (state.error && onRetry) {
|
||||
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); parent.append(retry);
|
||||
}
|
||||
}
|
||||
function setState(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
contentScope?.dispose(); contentScope = childScope(scope);
|
||||
container.replaceChildren();
|
||||
if (state.loading || state.error) {
|
||||
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted');
|
||||
status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status);
|
||||
if (state.error && state.refreshRequired) {
|
||||
if (onRefresh) {
|
||||
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
|
||||
contentScope.listen(refresh, 'click', onRefresh); container.append(refresh);
|
||||
} else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted'));
|
||||
} else if (state.error && onRetry) {
|
||||
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
render(state);
|
||||
}
|
||||
/**
|
||||
* The concept's imageDetails (app.js a400–414). The side panel has the cover, `.detail-inner` and `.detail-footer`;
|
||||
* the viewer (`full`) has a class-less wrapper that adds the two disclosures, with the footer buttons after it (D20).
|
||||
*/
|
||||
function render(state) {
|
||||
const inner = el('div', undefined, full ? undefined : 'detail-inner');
|
||||
if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;}
|
||||
const record = state.record;
|
||||
if (!record) return;
|
||||
const {file, library, metadata} = record;
|
||||
const {file, library} = record;
|
||||
const filename = record.identity.path.split('/').at(-1);
|
||||
if (inline && record.download) {
|
||||
const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview');
|
||||
const video = file?.media_type === 'video';
|
||||
const media = el(video ? 'video' : 'img'); media.src = record.download.url;
|
||||
if (video) {
|
||||
media.muted = true; media.playsInline = true; media.preload = 'metadata';
|
||||
contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();});
|
||||
} else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));}
|
||||
cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview);
|
||||
else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';}
|
||||
container.append(cover);
|
||||
}
|
||||
const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner);
|
||||
const header = el('div', undefined, 'file-title');
|
||||
header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename));
|
||||
const extension = file?.extension || filename.split('.').at(-1);
|
||||
header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip'));
|
||||
inner.append(header, el('p', filename, 'file-name muted'));
|
||||
const chips = el('div', undefined, 'file-chips');
|
||||
const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source));
|
||||
const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null;
|
||||
if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence'));
|
||||
if (library?.status === 'linked' && library.category) {
|
||||
const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category';
|
||||
chips.append(el('span', `${label}: ${library.category}`, 'file-chip'));
|
||||
}
|
||||
if (chips.childNodes.length) inner.append(chips);
|
||||
const linked = library?.status === 'linked';
|
||||
const title = linked && library.title ? library.title : filename;
|
||||
if (record.download && !full) container.append(cover(record));
|
||||
container.append(inner);
|
||||
const heading = el('div', undefined, 'panel-heading');
|
||||
heading.append(el('h2', title), file?.format ? el('span', file.format, 'chip') : prefixedChip('File extension ', extensionOf(record)));
|
||||
inner.append(heading, el('p', filename, 'small'));
|
||||
const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts);
|
||||
if (badge) badges.append(el('span', badge, 'chip blue'));
|
||||
if (linked && library.category) badges.append(prefixedChip(library.category_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category));
|
||||
if (badges.childNodes.length) inner.append(badges);
|
||||
const prompts = el('div'); inner.append(prompts);
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext});
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
|
||||
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
|
||||
if (library?.status === 'linked') {
|
||||
const vocabulary = el('div', undefined, 'file-vocabulary');
|
||||
vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted'));
|
||||
const tags = el('div', undefined, 'file-chips');
|
||||
for (const tag of library.tags) tags.append(el('span', tag, 'file-chip'));
|
||||
vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary);
|
||||
} else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted'));
|
||||
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts');
|
||||
inner.append(facts);
|
||||
if (FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) {
|
||||
const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', 'generation-limit muted');
|
||||
explanation.id = `file-fact-limit-${++factReasonId}`;
|
||||
FACTS.forEach(([key], index) => {
|
||||
if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id);
|
||||
if (linked && library.tags.length) {
|
||||
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
|
||||
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
|
||||
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again.
|
||||
const filterable = onTagFilter && library.tags_scope === 'positive';
|
||||
const tags = el('div', undefined, 'tags');
|
||||
for (const tag of library.tags) {
|
||||
if (!filterable) {tags.append(el('span', tag, 'chip')); continue;}
|
||||
const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip);
|
||||
}
|
||||
inner.append(tags);
|
||||
}
|
||||
inner.append(...generationFacts(record, 'meta-grid', 'small'));
|
||||
if (full) inner.append(provenance(record, badge), rawMetadata(record));
|
||||
const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts);
|
||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createButton('', 'send');
|
||||
pair.setAttribute('aria-label', `Send ${promptContext ? `${promptContext.toLowerCase()} ` : ''}prompt pair to ComfyUI: ${title}`);
|
||||
pair.append(createWorkspaceIcon('send'), ' Send to ComfyUI'); pair.dataset.sendRole = 'pair';
|
||||
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
|
||||
}
|
||||
if (record.historyTarget) {
|
||||
const history = createButton('');
|
||||
history.append(createWorkspaceIcon('branch'), ' Prompt history');
|
||||
// The concept's go(): the viewer closes, and a link to the route already shown re-renders it.
|
||||
contentScope.listen(history, 'click', () => {
|
||||
document.querySelector('dialog#viewer-dialog[open]')?.close();
|
||||
navigateTo(record.historyTarget);
|
||||
});
|
||||
inner.append(explanation);
|
||||
footer.append(history);
|
||||
}
|
||||
const provenance = disclosure('File & provenance');
|
||||
provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted'));
|
||||
if (record.identity.kind === 'reported-location') {
|
||||
const occurrence = record.identity.occurrence;
|
||||
provenance.append(definitionList([
|
||||
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
|
||||
], 'file-occurrence'));
|
||||
if (!full && footer.childNodes.length) container.append(footer);
|
||||
}
|
||||
/** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */
|
||||
function cover(record) {
|
||||
const {file} = record, video = file?.media_type === 'video';
|
||||
const node = el(video ? 'video' : 'img', undefined, 'detail-cover'); node.src = record.download.url;
|
||||
if (video) {
|
||||
node.muted = true; node.playsInline = true; node.preload = 'metadata';
|
||||
contentScope.cleanup(() => {node.pause(); node.removeAttribute('src'); node.load();});
|
||||
} else {
|
||||
node.alt = ''; node.decoding = 'async';
|
||||
if (file?.width && file?.height) {node.width = file.width; node.height = file.height;}
|
||||
contentScope.cleanup(() => node.removeAttribute('src'));
|
||||
}
|
||||
if (file) provenance.append(definitionList([
|
||||
['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)],
|
||||
return node;
|
||||
}
|
||||
/** "File & provenance": the current-file facts, the D21 prompt evidence, the occurrence and the saved association. */
|
||||
function provenance(record, badge) {
|
||||
const {file, library} = record, section = disclosure('File & provenance');
|
||||
const reported = record.identity.kind === 'reported-location';
|
||||
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'small'));
|
||||
const path = el('dd', record.identity.path ?? 'Not available');
|
||||
const facts = file && definitionList([
|
||||
['Source', file.source_label], ['Path', path], ['Size', formatBytes(file.size)],
|
||||
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
|
||||
['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type],
|
||||
['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type],
|
||||
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
|
||||
], 'file-facts'));
|
||||
else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted'));
|
||||
for (const [key, label] of FACTS) {
|
||||
const fact = record.generation[key];
|
||||
if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted'));
|
||||
], 'meta-grid');
|
||||
if (facts) {
|
||||
// D38: "Show in folder" follows the Path value.
|
||||
const target = api ? revealTarget(record.identity) : null;
|
||||
if (target) path.append(' ', revealButton(target));
|
||||
section.append(facts);
|
||||
} else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small'));
|
||||
// The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line.
|
||||
if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small'));
|
||||
if (reported) {
|
||||
const occurrence = record.identity.occurrence;
|
||||
section.append(definitionList([
|
||||
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
|
||||
], 'meta-grid'));
|
||||
}
|
||||
provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted'));
|
||||
section.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small'));
|
||||
if (record.download) {
|
||||
const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url;
|
||||
original.download = safeFilename(record.download.filename); provenance.append(original);
|
||||
const original = el('a', 'Download original'); original.href = record.download.url;
|
||||
original.download = safeFilename(record.download.filename); section.append(original);
|
||||
}
|
||||
inner.append(provenance);
|
||||
const raw = disclosure('Workflow & raw metadata');
|
||||
raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted'));
|
||||
if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted'));
|
||||
return section;
|
||||
}
|
||||
/** D38: "Show in folder" right after the Path value, for a listed file only; the server opens its file manager. */
|
||||
function revealButton(target) {
|
||||
const button = createButton('Show in folder');
|
||||
button.setAttribute('aria-label', `Show ${target.path.split('/').at(-1)} in folder`);
|
||||
let pending = false;
|
||||
contentScope.listen(button, 'click', async () => {
|
||||
if (pending) return;
|
||||
pending = true; button.setAttribute('aria-busy', 'true');
|
||||
let error = null;
|
||||
try {
|
||||
await api.post(REVEAL_URL, target, {signal:contentScope.signal});
|
||||
} catch (failure) {
|
||||
error = failure ?? {};
|
||||
}
|
||||
pending = false; button.removeAttribute('aria-busy');
|
||||
if (!contentScope.signal.aborted) showWorkspaceToast(revealOutcome(error));
|
||||
});
|
||||
return button;
|
||||
}
|
||||
/** "Workflow & raw metadata": each supported record (`pre[tabindex=0] > code`), then the two downloads. */
|
||||
function rawMetadata(record) {
|
||||
const {metadata} = record, section = disclosure('Workflow & raw metadata');
|
||||
section.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'small'));
|
||||
if (!metadata) section.append(el('p', 'Full metadata is unavailable for this inspection response.', 'small'));
|
||||
else {
|
||||
if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted'));
|
||||
if (metadata.status !== 'available') section.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'small'));
|
||||
for (const key of RAW_KEYS) {
|
||||
const format = metadata.record_formats[key]; if (!format) continue;
|
||||
raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted'));
|
||||
const name = el('p', undefined, 'small'); name.append(el('strong', key));
|
||||
section.append(name, el('p', REPRESENTATIONS[format], 'small'));
|
||||
if (Object.hasOwn(metadata.raw, key)) {
|
||||
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text);
|
||||
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); section.append(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
const downloads = inspectionDownloads(record);
|
||||
for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) {
|
||||
const item = downloads[key], action = createButton(label);
|
||||
const group = el('div', undefined, 'file-download'); group.append(action);
|
||||
section.append(action);
|
||||
if (!item.available) {
|
||||
action.setAttribute('aria-disabled', 'true');
|
||||
const explanation = el('p', item.reason, 'muted');
|
||||
const explanation = el('p', item.reason, 'small');
|
||||
// IDs need only be unique within the current document, including its two inspectors.
|
||||
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation);
|
||||
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); section.append(explanation);
|
||||
}
|
||||
contentScope.listen(action, 'click', () => {
|
||||
if (!item.available || contentScope.signal.aborted) return;
|
||||
@@ -221,19 +345,8 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
const anchor = el('a'); anchor.href = url; anchor.download = item.filename;
|
||||
container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000);
|
||||
});
|
||||
raw.append(group);
|
||||
}
|
||||
inner.append(raw);
|
||||
const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts);
|
||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton(promptContext ? `Send ${promptContext.toLowerCase()} prompt pair` : 'Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
|
||||
}
|
||||
if (record.historyTarget) {
|
||||
const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget;
|
||||
history.prepend(createWorkspaceIcon('branch')); footer.append(history);
|
||||
}
|
||||
if (footer.childNodes.length) container.append(footer);
|
||||
return section;
|
||||
}
|
||||
return {setState, dispose:() => scope.dispose()};
|
||||
}
|
||||
|
||||
@@ -114,6 +114,16 @@ function validateDetail(value, runId, epoch) {
|
||||
value.contexts.forEach(validateContext);
|
||||
requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length);
|
||||
}
|
||||
/**
|
||||
* Compare negatives (#249 D25): the selected negative's terms the baseline lacks, by the concept's rule (app.js a600-614):
|
||||
* split on commas, trim, drop empty terms, then an exact, case-sensitive difference that keeps the selected order and repeats.
|
||||
*/
|
||||
export function addedTerms(base, selected) {
|
||||
if (typeof base !== 'string' || typeof selected !== 'string') throw new TypeError('Negative texts must be strings');
|
||||
const terms = text => text.split(',').map(term => term.trim());
|
||||
const baseline = new Set(terms(base));
|
||||
return terms(selected).filter(term => term && !baseline.has(term));
|
||||
}
|
||||
export function frozenPromptRoles(context, mode) {
|
||||
requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context);
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => {
|
||||
|
||||
@@ -29,9 +29,10 @@ function place(parent, node, before) {
|
||||
else parent.insertBefore(node, before);
|
||||
}
|
||||
|
||||
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate}) {
|
||||
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate, onRecordedCount}) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
if (parentScope.signal.aborted) {dispose();return {dispose};}
|
||||
// openRun is a hoisted declaration: on an aborted parent it answers false before reading any later state.
|
||||
if (parentScope.signal.aborted) {dispose();return {dispose, openRun};}
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
const section = el('section', undefined, 'library-surface recorded-history');
|
||||
@@ -61,6 +62,9 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
|
||||
const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => {
|
||||
render(next);runView?.setState(next);
|
||||
if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);}
|
||||
// The history summary's "N tracked runs" (#249 D24): a completed window read's count; a read in flight keeps the last.
|
||||
const window = next.window;
|
||||
if (!window.loading) onRecordedCount?.(window.history_epoch && !window.error ? window.recorded_run_count : null);
|
||||
}});
|
||||
state = controller.state;
|
||||
runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker});
|
||||
@@ -218,5 +222,11 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
|
||||
});
|
||||
render(state);
|
||||
void controller.load();
|
||||
return {dispose};
|
||||
/** Open Recorded run for one run (the history run dot, #249 D26); false while recorded actions are unavailable. */
|
||||
function openRun({runId, anchorId = null, contextId = null, trigger}) {
|
||||
if (scope.signal.aborted || !state.actionsEnabled) return false;
|
||||
runView.open({runId, anchorId, contextId, trigger});
|
||||
return true;
|
||||
}
|
||||
return {dispose, openRun};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { countOf, noun } from './plural.mjs';
|
||||
|
||||
/**
|
||||
* The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category
|
||||
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
|
||||
*/
|
||||
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.';
|
||||
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
|
||||
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
|
||||
export const CATEGORY_MAX_LENGTH = 100;
|
||||
|
||||
function linkedPositive(image) {
|
||||
const link = image?.linked;
|
||||
if (link?.status !== 'linked') return null;
|
||||
return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null;
|
||||
}
|
||||
|
||||
/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */
|
||||
export function categoryTargets(images) {
|
||||
const ids = new Set();
|
||||
let skipped = 0;
|
||||
for (const image of images) {
|
||||
const id = linkedPositive(image);
|
||||
if (id === null) skipped += 1;
|
||||
else ids.add(id);
|
||||
}
|
||||
return {promptIds:[...ids], skipped};
|
||||
}
|
||||
|
||||
/** The success toast: the prompts changed, then any selected images that had no linked prompt. */
|
||||
export function categoryOutcome(prompts, skipped) {
|
||||
const done = `Category set on ${countOf(prompts, 'prompt')}.`;
|
||||
if (!skipped) return done;
|
||||
return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`;
|
||||
}
|
||||
|
||||
/** The prompts the server reports changed; without a count, the number of prompts posted. */
|
||||
export function updatedCount(result, posted) {
|
||||
const count = result?.updated_count;
|
||||
return Number.isSafeInteger(count) && count >= 0 ? count : posted;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { metadataEnvelope } from './file-inspector.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
|
||||
/** The Images selection bar's "Export metadata" (#249 D8): one JSON bundle of the inspector's envelopes. */
|
||||
export const BUNDLE_FILENAME = 'prompt-manager-images-metadata.json';
|
||||
const BUNDLE_FORMAT = 'prompt-manager-images-metadata';
|
||||
const EXPORT_CONCURRENCY = 4;
|
||||
|
||||
export function metadataBundle(envelopes, exportedAt) {
|
||||
return {format:BUNDLE_FORMAT, version:1, exported_at:exportedAt, images:[...envelopes]};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every image's inspection record, at most four at a time, and keep the envelopes in the images' order.
|
||||
* A read that fails, or a record without full metadata, counts as one image that could not be read.
|
||||
*/
|
||||
export async function collectImagesMetadata(images, read) {
|
||||
const envelopes = new Array(images.length).fill(null);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < images.length) {
|
||||
const index = next++;
|
||||
try {
|
||||
envelopes[index] = metadataEnvelope(await read(images[index]));
|
||||
} catch {
|
||||
// Counted below with the records that carry no full metadata.
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:Math.min(EXPORT_CONCURRENCY, images.length)}, worker));
|
||||
const found = envelopes.filter(envelope => envelope !== null);
|
||||
return {envelopes:found, failed:images.length - found.length};
|
||||
}
|
||||
|
||||
/** The export's toast; with nothing exported there is no download. */
|
||||
export function exportOutcome(exported, total) {
|
||||
if (!exported) return 'Metadata could not be read. Try again.';
|
||||
if (exported === total) return `Exported metadata for ${countOf(exported, 'image')}.`;
|
||||
return `Exported metadata for ${exported} of ${countOf(total, 'image')}. ${total - exported} could not be read.`;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
|
||||
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
|
||||
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
|
||||
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs';
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
const CHECKBOX = '.image-overlays input[type="checkbox"]';
|
||||
const CATEGORY_URL = '/prompt_manager/bulk/category';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
if (text !== undefined) element.textContent = text;
|
||||
if (className) element.className = className;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Images page's checked cards and the concept's selection bar (#249 D8; app.js renderImages, updateBulk and the
|
||||
* bulk handlers). `element` is the `#bulkbar` the page places between its filter row and gallery layout: empty while
|
||||
* nothing is checked, else "N selected" and the real actions in the concept's order. The page renders each card's
|
||||
* checkbox from `isChecked`; this module follows the boxes through the grid's `change` events.
|
||||
*
|
||||
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
|
||||
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
|
||||
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
|
||||
*/
|
||||
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
|
||||
let checked = initial;
|
||||
let exporting = false, categorizing = false;
|
||||
const element = el('div');
|
||||
element.id = 'bulkbar';
|
||||
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
|
||||
const setCategory = createButton('Set category');
|
||||
scope.listen(setCategory, 'click', () => openCategory(setCategory));
|
||||
const exportMetadata = createButton('Export metadata');
|
||||
scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata));
|
||||
const clearButton = createButton('Clear');
|
||||
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
|
||||
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
|
||||
bulk.append(count, setCategory, exportMetadata, clearButton);
|
||||
|
||||
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
|
||||
function render() {
|
||||
if (!checked.size) { bulk.remove(); return; }
|
||||
const label = selectionLabel(checked.size);
|
||||
if (count.textContent !== label) count.textContent = label;
|
||||
if (bulk.parentNode !== element) element.append(bulk);
|
||||
}
|
||||
function clear() {
|
||||
if (!checked.size) return;
|
||||
checked = Object.freeze(new Set());
|
||||
for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false;
|
||||
render();
|
||||
}
|
||||
function checkedImages() {
|
||||
return getItems().filter(image => checked.has(imageKey(image)));
|
||||
}
|
||||
// Every card carries its image key; its only form control is the checkbox, so a toggle never opens or inspects.
|
||||
scope.listen(grid, 'change', event => {
|
||||
const box = event.target;
|
||||
if (!box.matches(CHECKBOX)) return;
|
||||
checked = toggleSelection(checked, box.closest('article[data-image-key]').dataset.imageKey, box.checked);
|
||||
render();
|
||||
});
|
||||
|
||||
/** Busy until it settles: `aria-disabled` now, released in a `finally` so a throw cannot leave it stuck. */
|
||||
function busy(button) {
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
return () => button.removeAttribute('aria-disabled');
|
||||
}
|
||||
/**
|
||||
* Export metadata: the checked images' inspector envelopes in listing order, as one JSON download. A second press
|
||||
* while it runs is ignored and the selection is kept. Leaving the page aborts the reads: no download, no toast.
|
||||
*/
|
||||
async function exportChecked(button) {
|
||||
if (exporting) return;
|
||||
exporting = true;
|
||||
const release = busy(button);
|
||||
try {
|
||||
const { envelopes, failed } = await collectImagesMetadata(checkedImages(),
|
||||
async image => toInspectionRecord(await api.get(inspectPath(image), { signal: scope.signal }), image));
|
||||
if (scope.signal.aborted) return;
|
||||
if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString()));
|
||||
showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed));
|
||||
} finally {
|
||||
exporting = false;
|
||||
release();
|
||||
}
|
||||
}
|
||||
/** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */
|
||||
function downloadJson(filename, value) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
|
||||
let timer, released = false;
|
||||
const release = () => { if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url); };
|
||||
scope.cleanup(release);
|
||||
const anchor = el('a'); anchor.href = url; anchor.download = filename;
|
||||
element.append(anchor); anchor.click(); anchor.remove();
|
||||
timer = setTimeout(release, 1000);
|
||||
}
|
||||
/**
|
||||
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
|
||||
* distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change:
|
||||
* a toast says so and no dialog opens.
|
||||
*/
|
||||
function openCategory(button) {
|
||||
if (categorizing) return;
|
||||
const targets = categoryTargets(checkedImages());
|
||||
if (!targets.promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
|
||||
openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label,
|
||||
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) });
|
||||
}
|
||||
/**
|
||||
* The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page
|
||||
* not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`.
|
||||
*/
|
||||
async function applyCategory(button, { promptIds, skipped }, category) {
|
||||
categorizing = true;
|
||||
const release = busy(button);
|
||||
let result = null;
|
||||
try {
|
||||
result = await api.post(CATEGORY_URL, { prompt_ids: promptIds, category });
|
||||
} catch {
|
||||
// Reported below; the selection stays as it was.
|
||||
} finally {
|
||||
categorizing = false;
|
||||
release();
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
|
||||
showWorkspaceToast(categoryOutcome(updatedCount(result, promptIds.length), skipped));
|
||||
const focused = document.activeElement === button;
|
||||
onCategorySet();
|
||||
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
|
||||
if (focused) focusFallback.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
render();
|
||||
return {
|
||||
element,
|
||||
isChecked: key => checked.has(key),
|
||||
checked: () => checked,
|
||||
clear,
|
||||
/** Whether a Set category write is still in flight. */
|
||||
writing: () => categorizing,
|
||||
};
|
||||
}
|
||||
@@ -210,6 +210,11 @@ export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) {
|
||||
return `${source} · ${subfolder || 'All output folders'}`;
|
||||
}
|
||||
|
||||
/** D44: how many "Refine this view" filters (Source, Folder, Media, Tags) narrow the view, for the Filters label. */
|
||||
export function activeRefinements({ root = 'all', subfolder = '', media = 'all', tags = [] }) {
|
||||
return [root !== 'all' && root !== '', Boolean(subfolder), media !== 'all', tags.length > 0].filter(Boolean).length;
|
||||
}
|
||||
|
||||
/** D10: a card shows its confirmed linked positive's display title; any other link status shows the filename. */
|
||||
export function cardTitle(image) {
|
||||
const title = image.linked?.status === 'linked' ? image.linked.title : null;
|
||||
@@ -230,3 +235,16 @@ export function cardSubtitle(image) {
|
||||
export function mediaRatio(image) {
|
||||
return hasSize(image) ? `${image.width}/${image.height}` : '4/3';
|
||||
}
|
||||
|
||||
/** The card checkboxes' selection (#249 D8): a new frozen set with `key` added or removed; unchanged, the same set. */
|
||||
export function toggleSelection(selected, key, on) {
|
||||
if (selected.has(key) === on) return selected;
|
||||
const next = new Set(selected);
|
||||
if (on) next.add(key); else next.delete(key);
|
||||
return Object.freeze(next);
|
||||
}
|
||||
|
||||
/** The concept selection bar's count (app.js updateBulk), grouped like the page's other counts. */
|
||||
export function selectionLabel(count) {
|
||||
return `${count.toLocaleString('en-US')} selected`;
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/* The Images page renders the concept DOM (#249 PR C), so its replaced rules are retired. What remains is the side
|
||||
panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel and deletes with this file. */
|
||||
.images-main { container: images-page / inline-size; }
|
||||
.image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; }
|
||||
.image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; }
|
||||
.image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; }
|
||||
.image-details .workspace-link { display: flex; font-size: 11px; }
|
||||
@media (max-width: 700px) {
|
||||
.image-details { position: static; max-block-size: none; padding: 17px; }
|
||||
.image-details > :where(img, video) { block-size: 200px; }
|
||||
}
|
||||
@container images-page (inline-size < 600px) {
|
||||
.image-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
.image-details { position: static; max-block-size: none; }
|
||||
}
|
||||
.options-status { font-size: 12px; padding-inline: 4px; }
|
||||
|
||||
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
|
||||
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; }
|
||||
.prompt-positive { --prompt-role: #73dda9; }
|
||||
[data-ca-theme="light"] .prompt-positive { --prompt-role: #176b46; }
|
||||
.prompt-negative { --prompt-role: #ff8c98; }
|
||||
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; }
|
||||
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||
.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); }
|
||||
.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 10px; }
|
||||
.prompt-source { margin-block: 6px 10px; }
|
||||
/* #249 D22 interim: production's prompt blocks keep their text, action row and 26px buttons until PR D renders the
|
||||
concept prompt block and retires these three overrides. */
|
||||
.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; }
|
||||
.prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.prompt-actions { /* #249 D22 interim (PR D) */ display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ca-tools .prompt-actions button { /* #249 D22 interim (PR D) */ display: inline-flex; align-items: center; justify-content: center; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
|
||||
.prompt-actions .prompt-send-button span { display: none; }
|
||||
.prompt-actions .icon { inline-size: 14px; block-size: 14px; }
|
||||
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
|
||||
.prompt-copy-status:empty { display: none; }
|
||||
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); }
|
||||
.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
|
||||
.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; }
|
||||
.prompt-sender::backdrop { background: #000b; }
|
||||
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.prompt-sender-header h2 { font-size: 20px; margin: 0; }
|
||||
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; }
|
||||
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.prompt-sender select { inline-size: 100%; min-block-size: 38px; }
|
||||
.prompt-sender-status { font-size: 13px; line-height: 1.6; }
|
||||
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); }
|
||||
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; }
|
||||
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; }
|
||||
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; }
|
||||
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; }
|
||||
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; }
|
||||
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; }
|
||||
@media (max-width: 780px) {
|
||||
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
+68
-14
@@ -1,8 +1,9 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { mountSelectionBar } from './images-selection.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { promptRoles } from './prompt-blocks.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
@@ -13,6 +14,12 @@ import { mountToolbar } from './toolbar.mjs';
|
||||
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
/** The concept's panel without an image (app.js updateImages a456–458). */
|
||||
function noSelection() {
|
||||
const inner = el('div', undefined, 'detail-inner');
|
||||
inner.append(el('h2', 'No image selected'), el('p', 'Clear the filters or choose a matching image.', 'small'));
|
||||
return inner;
|
||||
}
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
if (text !== undefined) element.textContent = text;
|
||||
@@ -77,10 +84,16 @@ let lastVisit = null;
|
||||
// in flight and the page that receives each answer live here, beside the saved visit.
|
||||
const favoritePending = new Set();
|
||||
let favoriteTarget = null;
|
||||
/** Give a favorite state to the mounted Images page, or to the saved visit while none is mounted. */
|
||||
// Library-embedded galleries showing media cards (#249 PR E); keyed like the Images page, so each repaints its own copy.
|
||||
const embeddedFavoriteTargets = new Set();
|
||||
/**
|
||||
* Give a favorite state to the mounted Images page, or to the saved visit while none is mounted, and to every mounted
|
||||
* Library-embedded gallery.
|
||||
*/
|
||||
function deliverFavorite(key, favorite) {
|
||||
if (favoriteTarget) favoriteTarget(key, favorite);
|
||||
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
for (const target of embeddedFavoriteTargets) target(key, favorite);
|
||||
}
|
||||
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) {
|
||||
@@ -108,7 +121,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
|
||||
filters.tags = [...filters.tags];
|
||||
scope.cleanup(() => {
|
||||
if (!embedded) lastVisit = state ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, scrollY: window.scrollY } : null;
|
||||
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
|
||||
if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
cancelAnimationFrame(restoreFrame);
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
@@ -130,6 +144,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14).
|
||||
const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [],
|
||||
sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort},
|
||||
activeFilters:activeRefinements(filters),
|
||||
onChange:value => {
|
||||
if (recovering) return;
|
||||
const next = {q:value.q.trim(), category:value.category, sort:value.sort};
|
||||
@@ -205,12 +220,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
collection.append(continuation);
|
||||
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
|
||||
let focusAfterLoad = null;
|
||||
const details = el('section', undefined, 'image-details panel');
|
||||
details.setAttribute('aria-label', 'Image details');
|
||||
details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
|
||||
const details = el('aside', undefined, 'detail-panel');
|
||||
if (!embedded) details.id = 'image-inspector';
|
||||
details.setAttribute('aria-label', 'Selected image details');
|
||||
details.append(noSelection());
|
||||
body.append(collection, details);
|
||||
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
|
||||
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
|
||||
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback,
|
||||
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }});
|
||||
if (bar) main.append(bar.element);
|
||||
main.append(body);
|
||||
const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
|
||||
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => {
|
||||
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
|
||||
@@ -239,8 +261,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
detailInspector?.dispose();
|
||||
selection = imageKey(image);
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, inline:true,
|
||||
onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
|
||||
detailInspector.setState(inspection.state);
|
||||
inspection.select(image);
|
||||
for (const [key, card] of rendered) markSelected(card, key === selection);
|
||||
@@ -263,8 +285,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const open = createButton('', 'image-open');
|
||||
open.setAttribute('aria-label', `Open ${image.filename}`);
|
||||
open.append(mediaElement(image, true));
|
||||
// D7: the star, wherever the listing reports favorites (not on Library-embedded media); D8: no checkbox yet.
|
||||
// D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star,
|
||||
// wherever the listing reports favorites (Images and Library-embedded media).
|
||||
const overlays = el('div', undefined, 'image-overlays');
|
||||
if (bar) {
|
||||
const label = el('label'), box = el('input');
|
||||
box.type = 'checkbox'; box.checked = bar.isChecked(key);
|
||||
box.setAttribute('aria-label', `Select ${image.filename}`);
|
||||
label.append(box);
|
||||
overlays.append(label);
|
||||
}
|
||||
if (typeof image.favorite === 'boolean') {
|
||||
const star = createButton('', 'star-button');
|
||||
star.setAttribute('aria-label', `Favorite ${image.filename}`);
|
||||
@@ -299,10 +329,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const button = rendered.get(key)?.querySelector('.star-button');
|
||||
if (button) paintStar(button, favorite, favoritePending.has(key));
|
||||
}
|
||||
// Only the Images page receives favorite answers; Library-embedded media has no star (#249 D7).
|
||||
// The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E).
|
||||
if (!embedded) {
|
||||
favoriteTarget = setFavorite;
|
||||
scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; });
|
||||
} else {
|
||||
embeddedFavoriteTargets.add(setFavorite);
|
||||
scope.cleanup(() => embeddedFavoriteTargets.delete(setFavorite));
|
||||
}
|
||||
/** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
|
||||
async function toggleFavorite(image) {
|
||||
@@ -540,6 +573,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}
|
||||
function updateScope() {
|
||||
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
|
||||
toolbar?.setActiveFilters(activeRefinements(filters));
|
||||
}
|
||||
function reset() {
|
||||
mediaViewer.stopSlideshow();
|
||||
@@ -547,14 +581,23 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!recovering) {
|
||||
inspection.clear();
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
details.replaceChildren(noSelection());
|
||||
}
|
||||
selection = null;
|
||||
focusAfterLoad = null;
|
||||
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
|
||||
bar?.clear();
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
updateScope();
|
||||
stream.reset(filters, {load:!recovering});
|
||||
}
|
||||
/** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */
|
||||
function filterByTag(tag) {
|
||||
if (recovering || !tag.trim() || tag.includes(',')) return;
|
||||
if (filters.tags.length === 1 && filters.tags[0] === tag) return;
|
||||
filters.tags = [tag];
|
||||
reset();
|
||||
}
|
||||
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
|
||||
function openFilters(trigger) {
|
||||
if (recovering) return;
|
||||
@@ -606,15 +649,26 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (scope.signal.aborted) return;
|
||||
renderLoadStatus();
|
||||
if (found) reset();
|
||||
if (!scope.signal.aborted) showWorkspaceToast(rescanFinished(result));
|
||||
if (!scope.signal.aborted) announceRescan(rescanFinished(result));
|
||||
} catch {
|
||||
// A failed rescan leaves the collection and any discovery alert as they were.
|
||||
if (!scope.signal.aborted) showWorkspaceToast(RESCAN_FAILED);
|
||||
if (!scope.signal.aborted) announceRescan(RESCAN_FAILED);
|
||||
} finally {
|
||||
rescanning = false;
|
||||
importDialog?.setRescanBusy(false);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* D42: the concept's toast stays fixed at the bottom, so a discovery alert the rescan leaves is scrolled clear of
|
||||
* the toast's band (its `scroll-margin-block-end`) only when it is in that band; elsewhere the page stays put.
|
||||
*/
|
||||
function announceRescan(message) {
|
||||
showWorkspaceToast(message);
|
||||
if (optionsStatus.hidden) return;
|
||||
const {top, bottom} = optionsStatus.getBoundingClientRect();
|
||||
const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0;
|
||||
if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'});
|
||||
}
|
||||
async function refreshInspectionCollection() {
|
||||
if (scope.signal.aborted) return;
|
||||
// Numeric roots may now name another source; retire the old stream first.
|
||||
|
||||
@@ -153,8 +153,10 @@ function resolutionFor(value) {
|
||||
|
||||
function libraryFor(value) {
|
||||
if (!object(value) || !['linked', 'none', 'ambiguous', 'unavailable', 'limited'].includes(value.status) || value.scope !== 'saved-library' ||
|
||||
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') || ![null, 'pair', 'positive'].includes(value.metadata_scope)) throw new TypeError('Invalid inspection library');
|
||||
const library = { status: value.status, scope: value.scope, tags: [...value.tags], metadata_scope: value.metadata_scope };
|
||||
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') ||
|
||||
![value.category_scope, value.tags_scope].every(scope => [null, 'pair', 'positive'].includes(scope))) throw new TypeError('Invalid inspection library');
|
||||
// D39: each field names its own source, so a recipe's category can sit beside its positive's tags.
|
||||
const library = { status: value.status, scope: value.scope, tags: [...value.tags], category_scope: value.category_scope, tags_scope: value.tags_scope };
|
||||
for (const key of ['positive_id', 'pair_id', 'title', 'category']) {
|
||||
const item = value[key] ?? null;
|
||||
if (item !== null && (key.endsWith('_id') ? !Number.isSafeInteger(item) || item <= 0 : typeof item !== 'string')) throw new TypeError('Invalid inspection library field');
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
.image-details.file-inspector { padding: 0; }
|
||||
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.file-inspector-inner { padding: 17px; }
|
||||
#viewer-details .file-inspector-inner { padding: 0; }
|
||||
#viewer-details .file-inspector-footer { padding-inline: 0; }
|
||||
.ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); }
|
||||
.file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; }
|
||||
.file-inspector .file-title h2 { font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.file-name { font-size: 12px; margin-block: 12px; }
|
||||
.file-chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.file-chip { display: inline-block; padding: 3px 7px; border: 1px solid var(--ca-line); border-radius: 4px; background: var(--ca-field); font-size: 10px; }
|
||||
.file-evidence { color: var(--ca-focus); }
|
||||
.file-format { flex-shrink: 0; max-inline-size: 50%; }
|
||||
.file-inspector .prompt-inspector { margin-block-start: 15px; }
|
||||
.file-inspector .prompt-block { padding-block: 15px 0; margin-block-start: 15px; }
|
||||
.file-inspector .prompt-text { margin-block: 8px 0; }
|
||||
.file-inspector .prompt-negative { margin-block-end: 12px; }
|
||||
.file-vocabulary > p { margin-block: 0 6px; font-size: 10px; }
|
||||
.file-vocabulary .file-chip { font-size: 12px; }
|
||||
.generation-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 16px; font-size: 11px; }
|
||||
.generation-limit { font-size: 10px; line-height: 1.65; margin-block: 12px; }
|
||||
.generation-facts dt, .file-facts dt { color: var(--ca-muted); font-size: 10px; }
|
||||
.generation-facts dd, .file-facts dd { margin: 3px 0 0; }
|
||||
.file-inspector details { border-block-start: 1px solid var(--ca-line); margin-block-start: 16px; padding-block-start: 13px; font-size: 12px; }
|
||||
.file-inspector summary { cursor: pointer; min-block-size: 24px; }
|
||||
.file-inspector :where(summary, button, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.file-inspector details p { font-size: 10px; line-height: 1.65; }
|
||||
.file-facts { font-size: 11px; }
|
||||
.file-facts > div { margin-block: 10px; }
|
||||
.file-inspector details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 350px; overflow: auto; padding: 10px; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 4px; font-size: 11px; line-height: 1.6; }
|
||||
.raw-record-name { font-weight: 600; }
|
||||
.file-download { margin-block: 12px; }
|
||||
.ca-tools .file-download button { font-size: 11px; min-block-size: 30px; }
|
||||
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
|
||||
.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; }
|
||||
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
|
||||
.ca-tools #viewer-details .file-inspector-footer button { white-space: normal; }
|
||||
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
|
||||
.file-inspector-footer .icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-status { padding: 17px; }
|
||||
@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } }
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .file-inspector :is(button, a, summary) { min-inline-size: 40px; min-block-size: 40px; }
|
||||
.ca-tools .file-inspector .prompt-actions button { inline-size: 36px; block-size: 36px; min-inline-size: 36px; min-block-size: 36px; }
|
||||
}
|
||||
@@ -40,3 +40,29 @@ export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1)
|
||||
}
|
||||
return [...visible].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// #249 D23: the card footer's words. English month names as the concept's copy, independent of the browser locale.
|
||||
const MONTHS = Object.freeze(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']);
|
||||
const DAY_MS = 86400000;
|
||||
/** The local calendar day as a UTC day number, so a DST change never makes a day 23 or 25 hours long. */
|
||||
const localDay = date => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
|
||||
|
||||
/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>" (", <YYYY>" in another year), or "Not used yet". */
|
||||
export function usedLabel(lastUsedAt, now = new Date()) {
|
||||
const used = typeof lastUsedAt === 'string' && lastUsedAt ? new Date(lastUsedAt) : null;
|
||||
if (!used || Number.isNaN(used.getTime())) return 'Not used yet';
|
||||
// A server clock ahead of this browser is not a future use.
|
||||
const days = Math.max(0, localDay(now) - localDay(used));
|
||||
if (days === 0) return 'Used today';
|
||||
if (days === 1) return 'Used yesterday';
|
||||
if (days < 7) return `Used ${days} days ago`;
|
||||
// A use in another calendar year names it ("Used Oct 21, 2025"), so it never reads as this year's date.
|
||||
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
|
||||
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
|
||||
}
|
||||
|
||||
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
|
||||
export function ratingLabel(rating) {
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
|
||||
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ function parameters(filters,limit) {
|
||||
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
query.set('sort',sort);return query;
|
||||
}
|
||||
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
|
||||
function validatePage(page,cursor) {
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
}
|
||||
export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
|
||||
let query=parameters({},40),request,disposed=false,seen=new Set();
|
||||
if(restore) {
|
||||
query=parameters(restore.filters,40);const saved=restore.state;
|
||||
validatePage({items:saved.items,total:saved.total,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
|
||||
validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
|
||||
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
|
||||
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
|
||||
}
|
||||
@@ -37,11 +38,11 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
validatePage(page,cursor);const incoming=[];const added=new Set();
|
||||
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
|
||||
for(const value of added) seen.add(value);
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
|
||||
}
|
||||
return {get state(){return state;},loadMore,async reset(filters={}) {
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
|
||||
},dispose(){disposed=true;request?.abort();request=null;}};
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -1,48 +1,15 @@
|
||||
main.library-main { --library-positive: #73dda9; }
|
||||
.library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; }
|
||||
.library-main .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; }
|
||||
.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; }
|
||||
.library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; }
|
||||
.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; }
|
||||
.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; }
|
||||
.ca-tools .library-main .library-toolbar > button { inline-size: auto; }
|
||||
.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; }
|
||||
.library-main :where(.library-shelf, .library-history, .library-linked-images) { min-inline-size: 0; }
|
||||
.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; }
|
||||
.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; }
|
||||
.library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); }
|
||||
.library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; }
|
||||
.library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.library-main .library-card-art > :only-child { grid-column: 1 / -1; }
|
||||
.library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); }
|
||||
.library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; }
|
||||
.library-main .library-card-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
|
||||
.library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.library-main .library-title-link { color: var(--ca-text); text-decoration: none; }
|
||||
.library-main .library-title-link:hover { color: var(--ca-accent); }
|
||||
.library-main .library-card-prompts .prompt-block { margin: 0; padding-block: 10px 0; }
|
||||
.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); }
|
||||
.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; }
|
||||
.library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); }
|
||||
.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 10px; }
|
||||
.library-main .library-rating { color: var(--ca-muted); }
|
||||
.library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); }
|
||||
.library-main .library-history-link svg { inline-size: 18px; block-size: 18px; }
|
||||
.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; }
|
||||
.library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
||||
.library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); }
|
||||
.library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; }
|
||||
.library-main .library-branches { padding: 18px 20px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); margin-block: 16px; }
|
||||
.library-main .library-positive-lane { padding-block-end: 12px; border-block-end: 2px solid var(--library-positive); color: var(--library-positive); font-size: 12px; }
|
||||
.library-main .library-variant-lane { display: flex; align-items: center; gap: 16px; min-block-size: 57px; margin-inline-start: 18px; border-inline-start: 2px solid var(--ca-error); padding-inline-start: 18px; }
|
||||
.library-main .library-variant-lane span { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; color: var(--ca-muted); }
|
||||
.ca-tools .library-main .library-variant-dot { flex: 0 0 29px; inline-size: 29px; min-block-size: 29px; padding: 0; border-radius: 50%; background: var(--ca-error); border: 2px solid var(--ca-error); color: white; }
|
||||
.library-main .library-variant-dot[aria-pressed="true"] { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.library-main .library-variants > button[aria-pressed="true"] { border-color: var(--ca-focus); }
|
||||
.library-main .library-shared-gallery { margin-block-start: 24px; }
|
||||
.library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; }
|
||||
@media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
|
||||
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } }
|
||||
|
||||
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
|
||||
|
||||
@@ -50,24 +17,12 @@ main.library-main { --library-positive: #73dda9; }
|
||||
main.library-main { --library-negative: #ff8c98; }
|
||||
[data-ca-theme="light"] main.library-main { --library-negative: #b71931; }
|
||||
.library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; }
|
||||
.library-main .library-branches { overflow-x: auto; position: relative; }
|
||||
.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; }
|
||||
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
|
||||
.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
|
||||
.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; }
|
||||
.library-main .library-panel-heading p.muted { font-size: 12px; line-height: 1.5; margin: 0; }
|
||||
.library-main .library-variants .library-branches { margin-block: 0; }
|
||||
.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; }
|
||||
.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; }
|
||||
.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
|
||||
.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; }
|
||||
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; }
|
||||
.history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
|
||||
.history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
|
||||
.history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
|
||||
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; }
|
||||
.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
|
||||
/* D45: the panel's paging buttons wrap like the recorded toolbar's. */
|
||||
.ca-tools .library-main :where(.timeline-panel, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
|
||||
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; }
|
||||
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; }
|
||||
@@ -95,7 +50,7 @@ main.library-main { --library-negative: #ff8c98; }
|
||||
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; }
|
||||
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; }
|
||||
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; }
|
||||
.recorded-run-dialog :where(.muted:not(.prompt-text), .recorded-output-status) { font-size: 11px; line-height: 1.65; }
|
||||
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; }
|
||||
.recorded-provenance summary { cursor: pointer; }
|
||||
.recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; }
|
||||
@@ -115,7 +70,10 @@ main.library-main { --library-negative: #ff8c98; }
|
||||
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
|
||||
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); }
|
||||
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--library-negative); }
|
||||
.ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; }
|
||||
/* D45: a coarse pointer reaches the concept dot through a 47px target. */
|
||||
.ca-tools .library-history .variant-dot::after { content: ""; position: absolute; inset: -9px; }
|
||||
/* D45: the 12px repeat-run dot (8px inside its border) gets a 44px coarse target the same way. */
|
||||
.ca-tools .library-history .run-dot::after { content: ""; position: absolute; inset: -18px; }
|
||||
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
@@ -124,12 +82,18 @@ main.library-main { --library-negative: #ff8c98; }
|
||||
}
|
||||
|
||||
/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5).
|
||||
These .row-actions rules restyle the concept's own selector and are retired in PR E (library cards). */
|
||||
.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
|
||||
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
|
||||
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
|
||||
These .row-actions rules restyle the concept's own selector for the history panels only (the shelf's card footer
|
||||
keeps the concept's) and are retired in PR F (history). */
|
||||
.library-main .library-history .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .library-main .library-history .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .library-history .row-actions button { min-block-size: 40px; } }
|
||||
/* D45: archived pairs (#235) are concept lanes at reduced emphasis (the .6 disabled-marker opacity); text stays at full contrast. */
|
||||
.library-history .variant-lane.library-variant-archived::before, .ca-tools .library-history .library-variant-archived .variant-dot { opacity: .6; }
|
||||
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; } }
|
||||
/* D45: at 390px and 200% zoom the panel heading and the legend row wrap, and the All images button wraps its label,
|
||||
so the page never scrolls sideways (the retired production footer did the same). */
|
||||
.library-history .timeline-panel > .panel-heading, .library-history .timeline-lower { flex-wrap: wrap; }
|
||||
/* D45: while the repeat-run dot shows (left: 190px), the lane text wraps inside the 57px lane before it (43px + 139px). */
|
||||
.library-history .variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; }
|
||||
.ca-tools .library-history .timeline-lower > button { /* D45 */ max-inline-size: 100%; white-space: normal; }
|
||||
|
||||
+209
-70
@@ -1,4 +1,4 @@
|
||||
import {createScope,createButton,createField,createSelect} from '../shared/toolkit/core.js';
|
||||
import {createScope,createButton} from '../shared/toolkit/core.js';
|
||||
import {createPromptSender} from './prompt-sender.mjs';
|
||||
import {mountPromptBlocks} from './prompt-inspector.mjs';
|
||||
import {mountImages} from './images.mjs';
|
||||
@@ -6,11 +6,14 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr
|
||||
import {promptRoles} from './prompt-blocks.mjs';
|
||||
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
|
||||
import {createLibraryEditorController} from './library-editor-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
import {addedTerms} from './history-state.mjs';
|
||||
import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -23,11 +26,13 @@ const selectedPairs=new Map();
|
||||
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
|
||||
const metadataDrafts=new Map();
|
||||
const archivedVisible=new Map();
|
||||
// The concept's three sorts first, then the Library's appended Oldest first (D14).
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
|
||||
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
|
||||
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
|
||||
function el(tag,text,className) {
|
||||
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
|
||||
}
|
||||
function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;}
|
||||
// Cut at 80 UTF-16 units, never inside a surrogate pair (database/library.py titled() matches).
|
||||
export function libraryTitle(text) {
|
||||
const first=text.split('\n')[0].trim();
|
||||
@@ -144,20 +149,22 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
const toolbar=el('search',undefined,'library-toolbar');
|
||||
const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500;
|
||||
const category=createSelect([['','All categories']], '');category.value=filters.category;
|
||||
const tags=el('input');tags.type='text';tags.placeholder='Comma-separated tags';tags.value=filters.tags.join(', ');tags.maxLength=2000;
|
||||
const sort=createSelect([['newest','Recently added'],['oldest','Oldest first'],['name','Prompt A–Z'],['rating','Highest rated']], '');sort.value=filters.sort;
|
||||
const refresh=createButton('Refresh library');
|
||||
toolbar.append(createField('Search prompts',search,'library-search'),createField('Category',category,'library-category'),createField('Tags',tags,'library-tags'),createField('Sort',sort,'library-sort'),refresh);
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
const toolbarScope=createScope();
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf.
|
||||
const refinements=()=>filters.tags.length?1:0;
|
||||
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters});
|
||||
const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert');
|
||||
const caption=el('p','','library-caption');caption.setAttribute('role','status');
|
||||
// The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants.
|
||||
const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true;
|
||||
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
|
||||
captionCount.setAttribute('role','status');
|
||||
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
|
||||
const message=el('p','','library-status');message.setAttribute('role','status');
|
||||
const continuation=el('div',undefined,'library-continuation');
|
||||
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
|
||||
const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link');
|
||||
const actions=headingActions(main,section);
|
||||
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
|
||||
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt);
|
||||
@@ -184,7 +191,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
scope.listen(exportButton,'click',exportLibrary);
|
||||
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
|
||||
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section);
|
||||
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
|
||||
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section);
|
||||
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
|
||||
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
|
||||
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
|
||||
@@ -195,29 +203,37 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
|
||||
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
|
||||
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
|
||||
/** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */
|
||||
function cardFor(item,index) {
|
||||
const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
|
||||
const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true');
|
||||
const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
|
||||
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
|
||||
const previews=item.previews || [];
|
||||
for(const image of previews.slice(0,2)) {
|
||||
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','library-art-empty'));
|
||||
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','small'));
|
||||
else {
|
||||
const img=el('img');img.src=window.ApiClient.resolveUrl(image.thumbnail_url || image.url);img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img);
|
||||
}
|
||||
}
|
||||
if(!previews.length) art.append(el('span','No available image previews','library-art-empty'));
|
||||
const content=el('div',undefined,'library-card-content');
|
||||
const heading=el('header',undefined,'library-card-title');
|
||||
const name=link(displayTitle(item),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name);
|
||||
heading.append(h2,el('span',item.category || 'Uncategorized','library-chip'));
|
||||
const prompts=el('div',undefined,'library-card-prompts');
|
||||
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']});
|
||||
const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?countOf(item.variant_count,'saved negative variant'):'No saved negatives','library-chip library-chip-negative'),el('span',countOf(item.image_count,'linked image')));
|
||||
const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip'));
|
||||
content.append(heading,prompts,stats,vocabulary);
|
||||
const footer=el('footer',undefined,'library-card-footer');
|
||||
const rating=el('span',item.rating?'★'.repeat(item.rating)+'☆'.repeat(5-item.rating):'Unrated','library-rating');rating.setAttribute('role','img');rating.setAttribute('aria-label',item.rating?`Rated ${item.rating} out of 5`:'Unrated');footer.append(rating);
|
||||
const history=link('',`#/library/${item.id}`,'library-history-link');history.append(createWorkspaceIcon('branch'),el('span','Explore history'));footer.append(history);
|
||||
if(!previews.length) art.append(el('span','No available image previews','small'));
|
||||
const name=displayTitle(item);
|
||||
const content=el('div',undefined,'card-content');
|
||||
const heading=el('div',undefined,'card-title');const open=createButton(name);open.dataset.history='';
|
||||
heading.append(open,el('span',item.category || 'Uncategorized','chip'));
|
||||
const prompts=el('div');
|
||||
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive'],title:name});
|
||||
const meta=el('div',undefined,'card-meta');
|
||||
meta.append(el('span',`\u2212 ${countOf(item.variant_count,'negative variant')}`,'chip red'),el('span',countOf(item.run_count ?? 0,'run')),el('span',countOf(item.image_count,'image')));
|
||||
const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip'));
|
||||
content.append(heading,prompts,meta,vocabulary);
|
||||
const footer=el('div',undefined,'card-footer');
|
||||
const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at);
|
||||
const facts=el('span');
|
||||
if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);}
|
||||
else facts.textContent=used;
|
||||
const actions=el('div',undefined,'row-actions');
|
||||
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
|
||||
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
|
||||
actions.append(send,explore);footer.append(facts,actions);
|
||||
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
|
||||
}
|
||||
function captureAnchor() {
|
||||
@@ -285,7 +301,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
pendingMeasurements.clear();
|
||||
const restore=dirty?(pendingAnchor || anchor):null;
|
||||
if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?448:468});dirty=false;}
|
||||
if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?422:448});dirty=false;}
|
||||
const index=restore?indexesById.get(restore.id):undefined;
|
||||
const target=index===undefined?undefined:Math.max(0,layout.rows[Math.floor(index/columnCount)].top-restore.offset);
|
||||
renderWindow(target);
|
||||
@@ -315,14 +331,21 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
function focusCard(index,last=false) {
|
||||
if(index<0 || index>=state.items.length) return;
|
||||
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
|
||||
const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
|
||||
const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
|
||||
}
|
||||
// The title and Explore history open the prompt's history; the footer send sends the full positive (D23).
|
||||
scope.listen(grid,'click',event=>{
|
||||
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
|
||||
const item=state?.items[indexesById.get(Number(card.dataset.promptId))];if(!item) return;
|
||||
if(event.target.closest('[data-history]')) window.location.hash=`#/library/${item.id}`;
|
||||
const send=event.target.closest('[data-send-positive]');if(send) sender.open({positive:item.text},send);
|
||||
});
|
||||
scope.listen(grid,'keydown',event=>{
|
||||
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
|
||||
const index=Number(card.dataset.index);
|
||||
if(event.target.matches('a') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
|
||||
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
|
||||
else if(event.key==='Tab') {
|
||||
const controls=card.querySelectorAll('a,button');const next=index+(event.shiftKey?-1:1);
|
||||
const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1);
|
||||
const edge=event.shiftKey?controls[0]:controls[controls.length-1];
|
||||
if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);}
|
||||
}
|
||||
@@ -337,9 +360,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
|
||||
}
|
||||
state=next;
|
||||
caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`;
|
||||
if(next.variantTotal!==null) {
|
||||
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
|
||||
}
|
||||
const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':'';
|
||||
message.textContent=(next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
|
||||
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
|
||||
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
|
||||
schedule();scheduleLoad();
|
||||
}
|
||||
@@ -354,14 +380,29 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
optionsRequest?.abort();const request=optionsRequest=new AbortController();
|
||||
try {
|
||||
const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return;
|
||||
const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true;
|
||||
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}}
|
||||
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
|
||||
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
|
||||
}
|
||||
const reset=()=>{restoreScrollY=null;return stream.reset(filters);};
|
||||
scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200));
|
||||
scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200));
|
||||
scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();});
|
||||
scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore());
|
||||
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
|
||||
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
|
||||
function applyToolbar(value) {
|
||||
const next={text:value.q.trim(),category:value.category,sort:value.sort};
|
||||
if(Object.entries(next).every(([key,item])=>filters[key]===item)) return;
|
||||
Object.assign(filters,next);reset();
|
||||
}
|
||||
/** "Refine this view" with the Library's one extra filter, Tags (D14); Clear also clears the toolbar category. */
|
||||
function openFilters(trigger) {
|
||||
openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement,
|
||||
onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}});
|
||||
}
|
||||
function applyRefinement({tags,category=filters.category}) {
|
||||
const next=[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))];
|
||||
if(category===filters.category && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return;
|
||||
filters.category=category;filters.tags=next;reset();
|
||||
}
|
||||
scope.listen(more,'click',()=>stream.loadMore());
|
||||
// Retry also re-reads a category list that failed (D15: Refresh is gone).
|
||||
scope.listen(retry,'click',()=>{if(!optionsStatus.hidden)loadCategories();stream.loadMore();});
|
||||
scope.listen(window,'scroll',()=>{reconcileScroll();schedule();});
|
||||
// Mobile viewport resizing can adjust scroll before delivering its resize event.
|
||||
scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();});
|
||||
@@ -369,15 +410,15 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor();
|
||||
// A write while mounted makes this stream stale: keep only what still means something.
|
||||
shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
|
||||
stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset();
|
||||
}
|
||||
|
||||
function mountHistory() {
|
||||
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
|
||||
const body=el('div');history.append(body);
|
||||
// Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
|
||||
headingDescription(main,history,'One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
// Concept heading (app.js a591–597): ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
|
||||
headingDescription(main,history,'Prompt history · one positive, one shared gallery');
|
||||
const actions=headingActions(main,history);
|
||||
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
|
||||
@@ -420,21 +461,61 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
|
||||
}
|
||||
detail=state.detail;const content=contentScope;
|
||||
const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip);
|
||||
// The concept summary (app.js a598): category chip, positive and selected negative blocks, then the counts (D24).
|
||||
const summary=el('article',undefined,'surface');const categoryChip=el('span','','chip');summary.append(categoryChip);
|
||||
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
|
||||
const meta=el('div',undefined,'card-meta'),runsMeta=el('span'),imagesMeta=el('span'),variantsMeta=el('span');runsMeta.hidden=true;
|
||||
meta.append(runsMeta,imagesMeta,variantsMeta);summary.append(meta);
|
||||
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
|
||||
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
|
||||
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
|
||||
content.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
|
||||
const all=createButton('All images for this positive');
|
||||
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
|
||||
const panelHeading=el('div',undefined,'library-panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
|
||||
panelCopy.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
|
||||
const addNegative=createButton('+ Add negative'),showArchived=createButton('');showArchived.setAttribute('aria-pressed','false');
|
||||
rowActions.append(addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
|
||||
const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
|
||||
// The concept's negative variants panel (app.js a598, style.css 1059-1154): heading, the lanes, then the legend.
|
||||
const variants=el('section',undefined,'surface timeline-panel');variants.setAttribute('aria-label','Saved negative variants');
|
||||
const panelHeading=el('div',undefined,'panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
|
||||
panelCopy.append(el('h2','Negative variants'),el('p','Choose a red dot to change the negative and filter this gallery.','small'));
|
||||
// Compare negatives (aria-pressed) opens the comparison section below; Show archived is the approved #235 addition.
|
||||
const compare=createButton('Compare negatives'),addNegative=createButton('+ Add negative'),showArchived=createButton('');
|
||||
compare.setAttribute('aria-pressed','false');showArchived.setAttribute('aria-pressed','false');
|
||||
rowActions.append(compare,addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
|
||||
content.listen(compare,'click',()=>{compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true'));updateCompare();});
|
||||
// The concept's comparison (app.js a599-616, style.css 1155-1179): after the timeline panel, the baseline (the oldest
|
||||
// active variant, D25) beside the selected negative, then its added terms. Another dot updates it in place.
|
||||
const compareSection=el('section',undefined,'surface');let compareBlocks=[],compareKey=null;
|
||||
const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];};
|
||||
content.cleanup(disposeCompareBlocks);
|
||||
function compareBlock(text,label) {
|
||||
const item=el('div',undefined,'compare-item');
|
||||
// D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page.
|
||||
compareBlocks.push(mountPromptBlocks(item,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)}));
|
||||
compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'});
|
||||
return item;
|
||||
}
|
||||
function updateCompare() {
|
||||
if(detail.variant_total===0) compare.setAttribute('aria-pressed','false');
|
||||
compare.disabled=detail.variant_total===0;
|
||||
if(compare.getAttribute('aria-pressed')!=='true') {compareSection.remove();disposeCompareBlocks();compareKey=null;return;}
|
||||
if(variants.nextSibling!==compareSection) variants.after(compareSection);
|
||||
// D46: the oldest active variant may sit on an unloaded page; the baseline is never guessed from the newest lanes.
|
||||
const baseline=detail.variants_has_more?null:detail.variants.reduce((oldest,variant)=>!oldest || variant.id<oldest.id?variant:oldest,null);
|
||||
const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId) || [...lanes.values()].find(item=>item.entry.variant.id===baseline?.id);
|
||||
const selected=(activePairId!==null && activePairId!==undefined && recordFor(activePairId)) || baseline;
|
||||
const name=lane?laneName(lane.entry):'';
|
||||
const key=JSON.stringify([baseline?.id,baseline?.negative_text,selected?.id,selected?.negative_text,name,displayTitle(detail.positive),savedReadState]);
|
||||
if(key===compareKey) return;
|
||||
compareKey=key;disposeCompareBlocks();compareSection.replaceChildren(el('h2','Compare negative variants'));
|
||||
if(!baseline || !selected) {compareSection.append(el('p','Load more negative variants to reach the baseline.','small'));return;}
|
||||
const grid=el('div',undefined,'compare-grid');grid.append(compareBlock(baseline.negative_text,'Baseline'),compareBlock(selected.negative_text,'Selected'));
|
||||
compareSection.append(el('p',`Baseline compared with ${name}. Choose another red dot to change the comparison.`),grid,el('p','Added terms','small'));
|
||||
const terms=addedTerms(baseline.negative_text,selected.negative_text);
|
||||
if(terms.length) for(const term of terms) compareSection.append(el('span',`+ ${term}`,'diff-added'));
|
||||
else compareSection.append(el('p','No added terms.','small'));
|
||||
}
|
||||
const branches=el('div',undefined,'timeline');const positiveLane=el('div',undefined,'positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
|
||||
const lanes=new Map();const emptyNote=el('p','No saved negative variants. Existing image associations remain available.','muted');
|
||||
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
|
||||
// D45: the bounded page count stays a status for assistive technology; the concept shows no count line.
|
||||
const variantCount=el('p','','ca-visually-hidden');variantCount.setAttribute('role','status');
|
||||
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
|
||||
const restoreStatus=el('p','','muted library-restore-status');restoreStatus.setAttribute('role','alert');restoreStatus.hidden=true;
|
||||
const restoreAction=createButton('');restoreAction.hidden=true;let restoreHandler=null;
|
||||
@@ -471,13 +552,24 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
for(const [index,variant] of loadedArchived.entries()) entries.push({key:`archived:${variant.id}`,kind:'archived',variant,number:detail.variant_total+index+1});
|
||||
return entries;
|
||||
}
|
||||
/** D45: a dot names its pair by a title no other loaded lane shares, else by its number, so every name is unique. */
|
||||
// Titles compare trimmed (accessible names are whitespace-normalized), and a title that reads like a fallback
|
||||
// "variant N" is never used, so it cannot collide with an untitled lane's name.
|
||||
const trimmedTitle=variant=>typeof variant.title==='string'?variant.title.trim():'';
|
||||
function dotName(entry,entries) {
|
||||
const title=trimmedTitle(entry.variant);
|
||||
return title && !/^variant\s+\d+$/i.test(title) && entries.filter(other=>trimmedTitle(other.variant)===title).length===1?title:`variant ${entry.number}`;
|
||||
}
|
||||
const runCount=variant=>Number.isSafeInteger(variant.run_count) && variant.run_count>0?variant.run_count:0;
|
||||
function laneFor(entry) {
|
||||
let lane=lanes.get(entry.key);
|
||||
if(!lane) {
|
||||
const node=el('div',undefined,`library-variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','library-variant-dot');const label=el('span');
|
||||
node.append(button,label);lane={node,button,label,text:document.createTextNode(''),entry};label.append(lane.text);
|
||||
// Concept lane (app.js a598): the "−" dot, "<name> · N runs", then the repeat-run dot (D26).
|
||||
const node=el('div',undefined,`variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','variant-dot');const label=el('span');
|
||||
const runDot=createButton('','run-dot');runDot.hidden=true;
|
||||
node.append(button,label,runDot);lane={node,button,label,runDot,text:document.createTextNode(''),entry};label.append(lane.text);
|
||||
if(entry.kind==='archived') {lane.restore=createButton('Restore','library-variant-restore');label.append(lane.restore);content.listen(lane.restore,'click',()=>restorePair(lane.entry.variant.id,lane.restore));}
|
||||
content.listen(button,'click',()=>select(lane.entry.variant.id));lanes.set(entry.key,lane);
|
||||
content.listen(button,'click',()=>select(lane.entry.variant.id));content.listen(runDot,'click',()=>openRunDot(lane));lanes.set(entry.key,lane);
|
||||
}
|
||||
lane.entry=entry;return lane;
|
||||
}
|
||||
@@ -488,9 +580,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
for(const [index,entry] of entries.entries()) {
|
||||
const lane=laneFor(entry),name=laneName(entry);
|
||||
lane.node.style.setProperty('--lane-offset',`${38+index*47}px`);
|
||||
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative variant ${entry.number}`);
|
||||
const named=entry.kind==='separate'?'the saved negative variant outside the loaded list':dotName(entry,entries);
|
||||
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative ${named}`);
|
||||
lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current';
|
||||
lane.text.textContent=`${name} · ${countOf(entry.variant.image_count,'linked image')}${entry.kind==='archived'?' · Archived':''}`;
|
||||
lane.text.textContent=`${name} · ${countOf(runCount(entry.variant),'run')}${entry.kind==='archived'?' · Archived':''}`;
|
||||
// D26: the small dot appears only for a pair counted more than once.
|
||||
lane.runDot.hidden=runCount(entry.variant)<=1;lane.runDot.setAttribute('aria-label',`Show repeat run for ${named}`);lane.runDot.disabled=savedReadState!=='current';
|
||||
if(lane.restore) {lane.restore.setAttribute('aria-label',`Restore ${name}`);lane.restore.disabled=savedReadState!=='current' || restoringPairs.has(entry.variant.id);}
|
||||
if(previous.nextSibling!==lane.node) previous.after(lane.node);
|
||||
previous=lane.node;
|
||||
@@ -505,17 +600,19 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.';
|
||||
const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0;
|
||||
showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown));
|
||||
updateCompare();
|
||||
};
|
||||
const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');};
|
||||
updateParent=()=>{
|
||||
main.querySelector('h1').textContent=displayTitle(detail.positive);categoryChip.textContent=detail.positive.category || 'Uncategorized';
|
||||
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${displayTitle(detail.positive)}`);
|
||||
const name=displayTitle(detail.positive);
|
||||
main.querySelector('h1').textContent=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta();
|
||||
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${name}`);blocks?.setTitle(name);
|
||||
};
|
||||
let activePairId;
|
||||
updateParent();updateBranches(state);
|
||||
branches.append(emptyNote);
|
||||
const note=el('p','Saved combinations, not an execution timeline.','muted');
|
||||
const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all);
|
||||
variants.append(branches,variantError,restoreStatus,restoreAction,more,retry,variantFooter);
|
||||
const lower=el('div',undefined,'timeline-lower');lower.append(el('span','Green + positive · red − negative · small dot repeat run','small'),all);
|
||||
variants.append(branches,variantCount,variantError,restoreStatus,restoreAction,more,retry,lower);
|
||||
const context=el('p','','library-variant-context');context.setAttribute('role','status');
|
||||
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);
|
||||
@@ -549,7 +646,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(current.negative_text!==variant.negative_text || current.explicit_empty!==variant.explicit_empty) {changed=true;return variant;}
|
||||
return {...variant,...current};
|
||||
});
|
||||
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';
|
||||
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';updateMeta();
|
||||
savedReadState=changed || unobserved || additional?'stale':'current';
|
||||
parentStatus.textContent=changed?'A saved negative changed identity. This view retains its previous selection and gallery. Reload the saved Library view to inspect current data.':unobserved?(fresh.variants_has_more?'Some loaded saved negatives were not observed in this bounded revalidation page. Reload the saved Library view to validate them.':'Some loaded saved negatives were not present in the revalidation result. This view retains its previous selection and gallery.'):additional?'Saved Library choices changed. This view retains its previous selection and gallery. Reload the saved Library view to inspect current choices.':'Saved Library details revalidated.';
|
||||
reloadSaved.hidden=savedReadState==='current';
|
||||
@@ -568,23 +665,23 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{pendingSelection=null;loader.load(positiveId);});
|
||||
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
|
||||
mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
const recordedView=mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
|
||||
}});
|
||||
},onRecordedCount:count=>{runsMeta.hidden=count===null;runsMeta.textContent=count===null?'':countOf(count,'tracked run');}});
|
||||
// The heading carries the pair send, as in the concept; the saved texts keep their per-role actions.
|
||||
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false});
|
||||
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false,title:displayTitle(detail.positive)});
|
||||
function select(pairId,{force=false}={}) {
|
||||
if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return;
|
||||
if(pairId!==null && !recordFor(pairId)) pairId=null;
|
||||
activePairId=pairId;blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true});
|
||||
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
|
||||
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));
|
||||
const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId);
|
||||
const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId);
|
||||
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':index>=0?`Negative variant ${index+1} · same positive prompt`:`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`;
|
||||
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
|
||||
// An archived pair's images are read in the archived scope; the gallery stays authoritative.
|
||||
mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}});
|
||||
updateSend();
|
||||
updateSend();updateCompare();
|
||||
}
|
||||
/** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */
|
||||
async function rereadSelectedPair() {
|
||||
@@ -648,7 +745,49 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(pair) select(pair.id,{force:true});
|
||||
}
|
||||
current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply};
|
||||
content.listen(all,'click',()=>select(null));
|
||||
/** A bounded media dialog (D26): its gallery lives until the dialog closes or the view goes. */
|
||||
// One slot: each open replaces the previous dialog's scope, so repeated opens never pile up cleanups.
|
||||
let imagesDialogScope=null;content.cleanup(()=>{imagesDialogScope?.dispose();imagesDialogScope=null;});
|
||||
function openImagesDialog({title,intro,className,fetchPage,trigger}) {
|
||||
imagesDialogScope?.dispose();
|
||||
const dialogScope=createScope();imagesDialogScope=dialogScope;
|
||||
const grid=el('div',undefined,className);
|
||||
openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>{dialogScope.dispose();if(imagesDialogScope===dialogScope) imagesDialogScope=null;}});
|
||||
mountImages(grid,api,dialogScope,{embedded:true,fetchPage});
|
||||
}
|
||||
const imagesPage=(id,query,signal)=>api.get(`/prompt_manager/library/${id}/images?${query}`,{signal});
|
||||
/** Concept "All images for this positive" (a1282-1290): every image of the positive, Load more bounded. */
|
||||
function openAllImagesDialog(id,trigger) {
|
||||
openImagesDialog({title:'All images for this positive',trigger,fetchPage:({parameters,signal})=>imagesPage(id,new URLSearchParams(parameters),signal)});
|
||||
}
|
||||
/** Concept "Repeat run" (a1291-1304): the pair's newest linked image only. */
|
||||
function openRepeatRun(pair,trigger) {
|
||||
openImagesDialog({title:'Repeat run',intro:'Same positive and negative, another execution.',className:'spaced-15',trigger,fetchPage:async({parameters,signal})=>{
|
||||
const query=new URLSearchParams(parameters);query.set('pair_id',String(pair.id));query.set('limit','1');query.delete('cursor');
|
||||
if(pair.archived===true) query.set('archive','archived');
|
||||
const page=await imagesPage(positiveId,query,signal),images=Array.isArray(page?.images)?page.images.slice(0,1):page?.images;
|
||||
return {...page,images,total:Array.isArray(images)?images.length:page?.total,has_more:false,next_cursor:null};
|
||||
}});
|
||||
}
|
||||
/** D26: the run dot opens Recorded run for the pair's newest recorded execution, else Repeat run. */
|
||||
const runLookups=new Set();
|
||||
async function openRunDot(lane) {
|
||||
const pair=lane.entry.variant,trigger=lane.runDot;
|
||||
if(savedReadState!=='current' || runLookups.has(pair.id)) return;
|
||||
runLookups.add(pair.id);let run=null,failed=false;
|
||||
try {
|
||||
const value=await api.get(`/prompt_manager/library/${positiveId}/history/pairs/${pair.id}/newest`,{signal:content.signal,cache:'no-store'});
|
||||
run=value?.run && typeof value.run.run_id==='string'?value.run:null;
|
||||
} catch {
|
||||
failed=true;
|
||||
} finally {runLookups.delete(pair.id);}
|
||||
// The view may have gone stale while the lookup was in flight; a stale view opens nothing.
|
||||
if(content.signal.aborted || savedReadState!=='current') return;
|
||||
if(run && recordedView.openRun({runId:run.run_id,anchorId:run.anchor_id ?? null,contextId:run.context_id ?? null,trigger})) return;
|
||||
if(failed) showWorkspaceToast('Recorded runs could not be read. Showing the newest linked image.');
|
||||
openRepeatRun(pair,trigger);
|
||||
}
|
||||
content.listen(all,'click',()=>openAllImagesDialog(positiveId,all));
|
||||
const remembered=pendingSelection,outside=selectedPairs.get(positiveId);pendingSelection=null;
|
||||
if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);}
|
||||
if(archivedShown) archivedLoader.load(positiveId);
|
||||
|
||||
@@ -19,7 +19,7 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
|
||||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
|
||||
const scope = createScope();
|
||||
let active = null, retryVersion = 0;
|
||||
const dispose = () => scope.dispose();
|
||||
@@ -80,7 +80,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
|
||||
body.append(left,details);dialog.append(header,body);container.append(dialog);
|
||||
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
|
||||
const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
|
||||
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
|
||||
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
|
||||
function closeViewer(restore = true) {
|
||||
if (closed) return;
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createSendButton } from './prompt-sender.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { openCopyFallback } from './workspace-dialogs.mjs';
|
||||
|
||||
export function promptRoles(prompts) {
|
||||
return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text]));
|
||||
}
|
||||
|
||||
const sources = {
|
||||
library: 'Saved library prompt',
|
||||
execution: 'Recorded at execution',
|
||||
'graph-inferred': 'Inferred from the saved workflow',
|
||||
'embedded-parameters': 'Embedded generation parameters',
|
||||
'zeroed-conditioning': 'Zeroed conditioning (no negative text)',
|
||||
'saved-association': 'Saved library association',
|
||||
};
|
||||
// #249 D22: the concept's prompt block (app.js a298–302) carries no source line; a file's evidence is its panel badge
|
||||
// (D21) and its "File & provenance" line. Roles without a confirmed text keep these status strings.
|
||||
const statuses = {
|
||||
absent: 'Not recorded in this file.',
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
const fieldSources = {
|
||||
runtime_return: 'Runtime return',
|
||||
host_cache_output: 'Host cache output',
|
||||
graph_literal: 'Inferred graph literal',
|
||||
graph_semantics: 'Inferred graph semantics',
|
||||
};
|
||||
const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'});
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -41,37 +32,48 @@ function childScope(parentScope) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = ''}) {
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) {
|
||||
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
|
||||
// context so assistive technology never meets two identically named prompt regions in one dialog.
|
||||
const named = role => contextLabel ? `${contextLabel} ${role} prompt` : `${role} prompt`;
|
||||
const named = roleLabel => contextLabel ? `${contextLabel} ${roleLabel.toLowerCase()} prompt` : `${roleLabel} prompt`;
|
||||
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
|
||||
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
|
||||
// A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>").
|
||||
// The title is mutable so a saved title edited in place (setTitle below, #249 F1) renames the actions.
|
||||
let currentTitle = title || '';
|
||||
const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${currentTitle ? `: ${currentTitle}` : ''}`;
|
||||
// An aborted parent gets one inert handle; every method is the same no-op.
|
||||
const inert = () => {};
|
||||
if (parentScope.signal.aborted) return {update:inert, setState:inert, setTitle:inert, dispose:inert};
|
||||
const scope = childScope(parentScope);
|
||||
container.classList.add('prompt-inspector');
|
||||
scope.listen(container, 'click', async event => {
|
||||
if (!actionsEnabled) return;
|
||||
const send = event.target.closest('[data-send-role]');
|
||||
if (send) {
|
||||
if (send && (send.dataset.sendRole === 'pair' || Object.hasOwn(roles, send.dataset.sendRole))) {
|
||||
const role = send.dataset.sendRole;
|
||||
sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send);
|
||||
}
|
||||
const copy = event.target.closest('[data-copy-role]');
|
||||
if (copy) {
|
||||
const role = copy.dataset.copyRole;
|
||||
const status = copy.closest('section').querySelector('.prompt-copy-status');
|
||||
if (copy && Object.hasOwn(roles, copy.dataset.copyRole)) {
|
||||
// The concept's copy: the shared toast, or its "Copy prompt" select-to-copy dialog without a clipboard.
|
||||
const text = roles[copy.dataset.copyRole];
|
||||
try {
|
||||
await navigator.clipboard.writeText(roles[role]);
|
||||
if (!scope.signal.aborted) status.textContent = 'Copied.';
|
||||
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (!scope.signal.aborted) showWorkspaceToast('Prompt copied.');
|
||||
} catch {
|
||||
if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.';
|
||||
if (scope.signal.aborted) return;
|
||||
// A re-render during the pending write detaches the clicked button; Escape then returns to its successor.
|
||||
const trigger = copy.isConnected === false ? container.querySelector(`[data-copy-role="${copy.dataset.copyRole}"]`) : copy;
|
||||
copyFallback(text, {scope, trigger});
|
||||
}
|
||||
}
|
||||
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
|
||||
});
|
||||
let roles = {}, actionsEnabled = true;
|
||||
let roles = {}, actionsEnabled = true, current = {prompts};
|
||||
function render(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
current = state;
|
||||
roles = {};
|
||||
actionsEnabled = state.actionsEnabled !== false;
|
||||
container.replaceChildren();
|
||||
@@ -87,38 +89,32 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
return;
|
||||
}
|
||||
roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role)));
|
||||
for (const role of visibleRoles) {
|
||||
const title = `${role[0].toUpperCase() + role.slice(1)} prompt`;
|
||||
for (const role of visibleRoles.filter(name => Object.hasOwn(ROLE_LABELS, name))) {
|
||||
const record = state.prompts?.[role];
|
||||
const block = el('section', undefined, `prompt-block prompt-${role}`);
|
||||
block.setAttribute('aria-label', contextLabel ? named(role) : title);
|
||||
const heading = el('div', undefined, 'prompt-block-heading');
|
||||
heading.append(el('h3', title));
|
||||
const block = el('section', undefined, `prompt-section ${role}`);
|
||||
block.setAttribute('aria-label', named(role[0].toUpperCase() + role.slice(1)));
|
||||
const heading = el('div', undefined, 'prompt-heading');
|
||||
heading.append(el('span', ROLE_LABELS[role], 'role-label'));
|
||||
block.append(heading);
|
||||
if (Object.hasOwn(roles, role)) {
|
||||
const proof = record.evidence;
|
||||
block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
|
||||
else {
|
||||
const text = el('pre', roles[role], 'prompt-text');
|
||||
text.tabIndex = 0;
|
||||
block.append(text);
|
||||
}
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const send = createSendButton(`Send ${action(role)}`);
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
const copy = createButton('', 'ca-icon-button');
|
||||
copy.setAttribute('aria-label', `Copy ${action(role)}`);
|
||||
copy.title = `Copy ${action(role)}`;
|
||||
const copy = createButton('', 'icon-btn');
|
||||
copy.setAttribute('aria-label', actionName('Copy', role));
|
||||
copy.append(createWorkspaceIcon('copy'));
|
||||
copy.dataset.copyRole = role;
|
||||
copy.disabled = !actionsEnabled;
|
||||
const send = createButton('', 'send icon-btn');
|
||||
send.setAttribute('aria-label', actionName('Send', role, ' to ComfyUI'));
|
||||
send.append(createWorkspaceIcon('send'));
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
actions.append(copy, send);
|
||||
heading.append(actions);
|
||||
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
|
||||
block.append(status);
|
||||
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
|
||||
const text = el('p', roles[role] === '' ? `No ${role} prompt (explicitly empty)` : roles[role], 'prompt-text');
|
||||
// Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text.
|
||||
text.tabIndex = 0;
|
||||
block.append(text);
|
||||
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted'));
|
||||
container.append(block);
|
||||
}
|
||||
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
@@ -128,5 +124,10 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
}
|
||||
render({prompts});
|
||||
return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()};
|
||||
// A saved title edited in place renames the actions without a remount (the history summary, #249 F1).
|
||||
const setTitle = next => {
|
||||
const value = next || '';
|
||||
if (value !== currentTitle) {currentTitle = value;render(current);}
|
||||
};
|
||||
return {update:prompts => render({prompts}), setState:render, setTitle, dispose:() => scope.dispose()};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop
|
||||
const receiptNodes = new Map();
|
||||
const evidenceValues = new Map();
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onRefresh:refreshCurrent});
|
||||
function selectedReceipt() {
|
||||
const selected = latest.selection.receipt;
|
||||
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
|
||||
|
||||
@@ -77,8 +77,21 @@ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_C
|
||||
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
|
||||
}
|
||||
|
||||
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
|
||||
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
|
||||
|
||||
/** The concept's `go()` (app.js 292–297): a link to the route already shown re-renders it instead of doing nothing. */
|
||||
export function navigateTo(hash, win = window) {
|
||||
if (win.location.hash === hash) win.dispatchEvent(new Event(ROUTE_REFRESH_EVENT));
|
||||
else win.location.hash = hash;
|
||||
}
|
||||
|
||||
export function mountRouter(win, onRoute) {
|
||||
let current;
|
||||
const refresh = () => {
|
||||
current = undefined;
|
||||
update();
|
||||
};
|
||||
const update = () => {
|
||||
const route = resolveRoute(win.location.hash);
|
||||
const hash = route.hash || `#/${route.id}`;
|
||||
@@ -88,6 +101,10 @@ export function mountRouter(win, onRoute) {
|
||||
onRoute(route);
|
||||
};
|
||||
win.addEventListener('hashchange', update);
|
||||
win.addEventListener(ROUTE_REFRESH_EVENT, refresh);
|
||||
update();
|
||||
return () => win.removeEventListener('hashchange', update);
|
||||
return () => {
|
||||
win.removeEventListener('hashchange', update);
|
||||
win.removeEventListener(ROUTE_REFRESH_EVENT, refresh);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
|
||||
* Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
|
||||
* `select#sort`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options first and a
|
||||
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16).
|
||||
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16); `Filters` reads
|
||||
* "Filters · N" while N "Refine this view" filters are active (D44).
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
@@ -16,6 +17,12 @@ const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'High
|
||||
|
||||
const isText = value => typeof value === 'string';
|
||||
|
||||
/** The Filters label: plain without active refinements, else "Filters · N" (D44). Its text is its accessible name. */
|
||||
function filtersLabel(count) {
|
||||
if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('The active filter count must be a whole number');
|
||||
return count ? `Filters · ${count}` : 'Filters';
|
||||
}
|
||||
|
||||
/** `sorts` as [value, label] pairs, the concept's options first in its order, then the rest in the given order (D14). */
|
||||
export function orderSorts(sorts) {
|
||||
if (!Array.isArray(sorts) || sorts.length === 0 ||
|
||||
@@ -48,10 +55,11 @@ function categoryOptions(categories) {
|
||||
|
||||
/**
|
||||
* Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
|
||||
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view".
|
||||
* The toolbar leaves `container` when `scope` is disposed. Returns `{element, value, setValue, setCategories}`.
|
||||
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view";
|
||||
* `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
|
||||
* Returns `{element, value, setValue, setCategories, setActiveFilters}`.
|
||||
*/
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, onChange, onFilters }) {
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters }) {
|
||||
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
|
||||
throw new TypeError('The toolbar needs the page scope');
|
||||
}
|
||||
@@ -83,7 +91,7 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
sort.setAttribute('aria-label', 'Sort results');
|
||||
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
|
||||
|
||||
const filters = createButton('Filters');
|
||||
const filters = createButton(filtersLabel(activeFilters));
|
||||
root.append(label, category, sort, filters);
|
||||
|
||||
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
|
||||
@@ -97,6 +105,9 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
category.replaceChildren(...categoryOptions(next));
|
||||
category.value = [...category.options].some(node => node.value === selected) ? selected : '';
|
||||
}
|
||||
function setActiveFilters(count) {
|
||||
filters.textContent = filtersLabel(count);
|
||||
}
|
||||
setValue(value);
|
||||
|
||||
const report = () => onChange(read());
|
||||
@@ -107,5 +118,5 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
scope.cleanup(() => root.remove());
|
||||
|
||||
container.append(root);
|
||||
return { element: root, value: read, setValue, setCategories };
|
||||
return { element: root, value: read, setValue, setCategories, setActiveFilters };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
* - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder →
|
||||
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
|
||||
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
|
||||
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
|
||||
* action; used by the Images selection bar's "Set category" (#249 D8).
|
||||
* - Content (a1282–1304 `dialog(title, body)`): a page-built body such as the history's "All images for this
|
||||
* positive" and "Repeat run" media (#249 D26); `onClose` runs once when that body leaves the shell.
|
||||
*
|
||||
* Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page.
|
||||
*/
|
||||
@@ -67,9 +71,10 @@ function createShell() {
|
||||
/** Close the open dialog, if any, and focus its trigger while that is still on the page. */
|
||||
function finish() {
|
||||
if (!session) return;
|
||||
const { trigger } = session;
|
||||
const { trigger, onClose } = session;
|
||||
session = null;
|
||||
if (shell?.dialog.open) shell.dialog.close();
|
||||
onClose?.();
|
||||
if (trigger?.isConnected) trigger.focus();
|
||||
}
|
||||
|
||||
@@ -95,11 +100,14 @@ function bindScope(scope) {
|
||||
});
|
||||
}
|
||||
|
||||
function show(heading, nodes, { scope, trigger, focusTarget }) {
|
||||
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) {
|
||||
shell ??= createShell();
|
||||
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
|
||||
bindScope(scope);
|
||||
session = { scope, trigger: trigger ?? session?.trigger ?? null };
|
||||
// A swapped-out body is gone from the shell, so its owner releases it.
|
||||
const replaced = session?.onClose;
|
||||
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose };
|
||||
replaced?.();
|
||||
shell.title.textContent = heading;
|
||||
shell.body.replaceChildren(...nodes);
|
||||
if (!shell.dialog.open) shell.dialog.showModal();
|
||||
@@ -257,3 +265,43 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
|
||||
], { scope, trigger, focusTarget: area });
|
||||
area.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
|
||||
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
|
||||
* A positive `maxLength` limits the field.
|
||||
*/
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onApply });
|
||||
for (const [name, text] of Object.entries({ title, label, action })) {
|
||||
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
|
||||
}
|
||||
const input = el('input');
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength;
|
||||
const cancel = createButton('Cancel');
|
||||
const apply = createButton(action, 'ca-primary');
|
||||
cancel.addEventListener('click', () => finish());
|
||||
apply.addEventListener('click', () => {
|
||||
const entered = input.value.trim();
|
||||
if (!entered) { input.focus(); return; }
|
||||
finish();
|
||||
onApply(entered);
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, apply);
|
||||
show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input });
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or
|
||||
* another dialog replaces its body, so the page can dispose what it mounted there.
|
||||
*/
|
||||
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) {
|
||||
requireScope(scope);
|
||||
if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title');
|
||||
if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes');
|
||||
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
|
||||
show(title, nodes, { scope, trigger, onClose });
|
||||
}
|
||||
|
||||
@@ -68,9 +68,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* #249 D18: the concept zooms a hovered card image (1.03 over 0.2s; its reduced-motion rule makes that instant). A touch
|
||||
device has no real hover, so a tap must not leave the zoom stuck. */
|
||||
@media (hover: none) { .image-open:hover img { transform: none; /* D18 */ } }
|
||||
/* #249 D8 (interim until the checkbox follow-up PR): without the concept's selection checkbox the star is the overlay's
|
||||
only child; this keeps it at the concept's top-right. The follow-up that ships the checkbox deletes this rule. */
|
||||
.image-overlays > .star-button:only-child { margin-left: auto; /* D8 */ }
|
||||
|
||||
/* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every
|
||||
layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is
|
||||
@@ -131,6 +128,10 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
@media (pointer: coarse) {
|
||||
/* #249 D34: coarse pointers keep 40px square viewer targets (the concept's "−" and "+" are narrower). */
|
||||
#viewer-dialog :is(.dialog-header > button, #viewer-toolbar > button, #viewer-toolbar > a) { min-width: 40px; min-height: 40px; /* D34 */ }
|
||||
/* #249 D34: the details' disclosure summaries and "Download original" keep 40px targets too (the concept's coarse
|
||||
rule covers buttons only); the summary keeps its disclosure marker. */
|
||||
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; /* D34 */ }
|
||||
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; /* D34 */ }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
#viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ }
|
||||
@@ -138,15 +139,91 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
|
||||
/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text
|
||||
that keep their own design until their page PR rebuilds them in concept markup. */
|
||||
.panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */
|
||||
.ca-tools dialog.prompt-sender { width: min(760px, calc(100% - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; } /* PR D */
|
||||
.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */
|
||||
.ca-tools dialog.recorded-run-dialog { width: min(1100px, calc(100% - 32px)); max-width: calc(100% - 32px); max-height: calc(100dvh - 32px); padding: 22px; border-radius: 7px; } /* PR F */
|
||||
pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; } /* PR D: prompt text becomes `p.prompt-text` */
|
||||
.ca-tools .prompt-actions .ca-icon-button { min-width: 30px; } /* PR D: the concept's 28px prompt action would shrink the toolkit icon button */
|
||||
.prompt-text[tabindex]:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; } /* production-only: a confirmed prompt stays keyboard-reachable (#249 D22) */
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */
|
||||
}
|
||||
|
||||
/* Images side panel (#249 D2), production-only. Real prompts can be far longer than the concept's samples, so the sticky
|
||||
panel scrolls within the window and its footer stays reachable; where the concept unsticks it, it grows instead. */
|
||||
.detail-panel { min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; /* D2 */ }
|
||||
@media (max-width: 700px) { .detail-panel { max-block-size: none; /* D2 */ } }
|
||||
@container images-page (inline-size < 600px) { .detail-panel { position: static; max-block-size: none; /* D2 */ } }
|
||||
/* #249 D2/D20: the prompt blocks' mount point is transparent, so their sections sit in `.detail-inner` (panel) and the
|
||||
viewer details' wrapper as the concept's do. */
|
||||
:is(.detail-inner, #viewer-details > div) > .prompt-inspector { display: contents; /* D20 */ }
|
||||
/* #249 D2: real titles and filenames can be one unbroken word; they wrap inside the 310px panel. */
|
||||
.panel-heading h2, .detail-inner > p.small { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* #249 D3: production-only rules kept from the retired images.css and inspector.css. Each has a live consumer. */
|
||||
/* The inspector wraps real unbroken literals (model paths, raw records) inside the 310px panel and the 340px viewer
|
||||
details, and an unavailable download stays visibly inert next to its reason. */
|
||||
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
|
||||
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
|
||||
where the toolkit's `nowrap` would push them past its edge. */
|
||||
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
|
||||
/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */
|
||||
.images-main { container: images-page / inline-size; }
|
||||
@container images-page (inline-size < 600px) {
|
||||
.image-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
/* #249 D42: a discovery alert a rescan leaves scrolls clear of the concept's fixed toast (22px inset, 12px at 700px and
|
||||
below, two 12px lines at 390px), never under it; the toast itself keeps the concept's placement. */
|
||||
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 96px; /* D42 */ }
|
||||
/* Until their pages rebuild (PR F history): the history page's and the recorded run's prompt blocks keep their
|
||||
spacing (on the shelf's cards `.prompt-inspector` is `display: contents`, so its margin is inert there), the Archive
|
||||
negative dialog keeps `section.prompt-block`, and the shared prompt sender keeps its dialog and send button. */
|
||||
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
|
||||
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; }
|
||||
.prompt-negative { --prompt-role: #ff8c98; }
|
||||
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; }
|
||||
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||
.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); }
|
||||
.prompt-empty, .prompt-block > .muted { font-size: 10px; }
|
||||
/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */
|
||||
pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; }
|
||||
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); }
|
||||
.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
|
||||
.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; }
|
||||
.prompt-sender::backdrop { background: #000b; }
|
||||
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.prompt-sender-header h2 { font-size: 20px; margin: 0; }
|
||||
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; }
|
||||
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.prompt-sender select { inline-size: 100%; min-block-size: 38px; }
|
||||
.prompt-sender-status { font-size: 13px; line-height: 1.6; }
|
||||
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); }
|
||||
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; }
|
||||
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; }
|
||||
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; }
|
||||
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; }
|
||||
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; }
|
||||
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; }
|
||||
@media (max-width: 780px) {
|
||||
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */
|
||||
/* #249 D23: real prompts are far longer than the concept's samples, so the card clamps the positive to two lines; copy
|
||||
and send keep the full text, and history shows all of it. */
|
||||
.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; /* D23 */ }
|
||||
/* #249 D23: the prompt blocks' mount point is transparent, so the section sits in `.card-content` as the concept's does. */
|
||||
.card-content > .prompt-inspector { display: contents; /* D23 */ }
|
||||
/* #249 D24: the history summary's prompt blocks sit in `article.surface` as the concept's do (app.js a598); the mount
|
||||
point keeps its "Saved prompt texts" region name. */
|
||||
.library-history article.surface > .prompt-inspector { display: contents; /* D24 */ }
|
||||
/* #249 D23: a real title can be long or one unbroken word; it wraps inside the title row (the toolkit's buttons never
|
||||
wrap), while the category chip keeps whole words beside it and wraps only a name wider than its share of the row. */
|
||||
.card-title button { min-width: 0; white-space: normal; overflow-wrap: anywhere; /* D23 */ }
|
||||
.card-title .chip { flex-shrink: 0; max-width: 60%; overflow-wrap: anywhere; /* D23 */ }
|
||||
.prompt-card .tags .chip { max-width: 100%; overflow-wrap: anywhere; /* D23 */ }
|
||||
/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */
|
||||
.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ }
|
||||
.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
<link rel="stylesheet" href="js/shared/toolkit/controls.css">
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
<link rel="stylesheet" href="js/workspace/concept.css">
|
||||
<link rel="stylesheet" href="js/workspace/images.css">
|
||||
<link rel="stylesheet" href="js/workspace/library.css">
|
||||
<link rel="stylesheet" href="js/workspace/inspector.css">
|
||||
<link rel="stylesheet" href="js/workspace/workspace.css">
|
||||
<script src="lib/viewerjs/viewer.min.js" defer></script>
|
||||
<script src="js/comfy-metadata.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user