Compare commits

...
Author SHA1 Message Date
Vito Sansevero 963c671747 refactor(images): move the selection bar into images-selection.mjs and harden its actions
- images.mjs is back under 800 lines; the bar, its checked set, Export metadata and Set category live in images-selection.mjs.
- Busy flags release in finally; the dead guards are gone and the category targets are taken when the modal dialog opens.
- The category write no longer carries the page signal; leaving Images mid-write drops the saved visit so the return reloads.
- The categories and the listing reload together after Set category.
- The Category field stops at the server's 100 characters; the server's updated_count is a tested pure helper.
- New browser tests: leaving mid-export and mid-write, Blob URL revoke, updated_count 0 and a 400 rejection.
- Additive coverage baseline entries for images-category, images-export and images-selection.
- TESTING.md D8 records the dialog deviations and these rulings.
2026-10-20 15:37:51 -07:00
Vito Sansevero 4a5dade10d fix(api): answer 400 for bulk category ids beyond SQLite's INTEGER range
An id of 2**63 or more passed the parser and made sqlite3 raise OverflowError, which the route turned into a 500.
2026-10-20 15:37:50 -07:00
Vito Sansevero 92da267172 test(images): zoomed card keyboard order follows the concept's checkbox before the star
The card's Tab order is image, Select checkbox, star, Inspect (concept app.js:398), both ways; List hides the overlays as the concept does, so there the image is followed by Inspect.
2026-10-20 15:37:50 -07:00
Vito Sansevero 8784a38e40 feat(images): set the category of the selected images' linked prompts
The selection bar's Set category opens the concept's simpleInput dialog
("Categorize selected images", one Category field, Cancel / Apply) and
posts the selection's distinct linked positive ids to
/prompt_manager/bulk/category. Images have no category of their own; the
listing's category filter matches the linked positive's, so that is what
changes. With no linked image checked nothing is posted and a toast says
so; on success the toast counts the prompts and any skipped images, then
the categories and the listing reload (clearing the selection); a failure
keeps the selection. The button ignores a second press while it runs.

The bulk category route now rejects anything but a non-empty list of at
most 1000 positive integer ids (deduplicated in order) and validates the
category with validate_category; bad JSON or a non-object body is a 400.
The response shape and the legacy admin request are unchanged.
2026-10-20 14:27:46 -07:00
Vito Sansevero 5b3896bf26 feat(images): export the selected images' metadata as one JSON bundle
The selection bar's Export metadata reads each checked image's inspection,
at most four at a time, and downloads prompt-manager-images-metadata.json:
format, version 1, exported_at and, in listing order, the same envelope the
inspector's "Download metadata JSON" saves (one shared builder,
metadataEnvelope). The button is aria-disabled and ignores a second press
while it runs, the selection is kept, and the toast reports the exported
count, the images that could not be read, or that none could (no download).

Also: the bar and its count are built only on the Images page, the
Library-embedded gallery is asserted to have no checkbox or bar, a sort
change is asserted to clear the selection, the checkbox label test asserts
the toggle and an unchanged selection and inspector, and TESTING.md records
that a return to Images keeps the selection while a reload starts empty.
2026-10-20 14:11:47 -07:00
Vito Sansevero d7f1b9987f feat(images): card selection checkbox and the concept selection bar
Every Images card's overlay starts with the concept's "Select <filename>"
checkbox before the star. Checked image keys form the page's selection,
kept across Load more, infinite scroll and bounded re-mounting and cleared
whenever the collection is replaced. The #bulkbar between the filter row
and the gallery shows "N selected" with Set category, Export metadata and
Clear (the first two are wired by the next tasks); Clear empties it.

The interim D8 star rule is removed, and the concept comparison now
measures the overlay with its checkbox and the bar with one card checked.
2026-10-20 13:54:09 -07:00
Vito 86d82186ee feat(images): concept Images page — toolbar, cards, star, viewer, compared against the mock (#258)
* feat(workspace): shared concept toolbar and dialogs

Add the concept's shared page toolbar (search.toolbar with the search
field, category filter, sort and Filters) and its dialogs in one modal
shell: "Refine this view" (Source / Folder / Media / Tags, Apply filters),
"Import & inspect" with only the real operations (Open inspector, Rescan),
and the "Copy prompt" select-to-copy fallback. The sort lists the
concept's three options first (D14); there is no Save view (D16). Every
dialog returns focus to its trigger on close or Escape.

Refs #249

* fix(workspace): bind shared dialogs to the page and its route

- Drop the invented Refine note; every field is real (D14).
- Ignore a late close event once the shell was reopened or replaced, so
  an action that opens the next dialog keeps it open.
- Dialog openers require the page scope; disposing it closes and removes
  the shell unless a newer page's dialog is open. Every route change
  closes the shared dialog before the next page mounts.
- Clear filters also reports category '' so the page clears the toolbar
  category, as the concept does.
- The toolbar requires its scope and falls back to the first sort for an
  unknown value; dialog callbacks are validated up front.

Refs #249

* feat(images): concept toolbar, filter row and caption

The Images page now renders the approved mock's heading, shared toolbar,
filter row, section caption and infinite continuation (#249, Task C2).

- Heading: "Your generations, their prompts, and the next idea." with
  Import (upload icon) before New prompt (primary).
- Toolbar (shared mountToolbar): search over filenames, linked prompt
  text, titles and tags; category (the Library's categories); sort
  Recently added / Name A–Z / Highest rated, then Oldest first and
  Name Z–A (D14).
- Filter row: Grid/List/Masonry segmented control, Thumbnail size,
  the scope chip ("All output folders" or "<source> · <folder>") and
  "Group by positive prompt →".
- Caption: "N of M images · K positive prompts" from positive_count; a
  plain cursor page keeps the first page's count and an unknown count
  hides the "· K positive prompts" part. The date span shows
  "<Today|Yesterday|weekday> · <Month D>" of the newest loaded result
  under a date sort and is hidden otherwise (D17).
- Continuation: "N of M images · more load as you scroll" with Load
  more, the concept's end copy, Retry for a failed page, and the
  concept's "K more images loaded. N of M." toast.
- The listing sends category, tags and sort=rating (PR B params).

Old control -> new control:
- "Search filenames" field -> toolbar search "Search workspace"
  (placeholder "Search prompts, tags, filenames…"), also matching
  prompt text and tags
- Source select -> Filters -> "Refine this view" Source
- Folder select -> Refine Folder (all sources' folders and ancestors)
- Media select -> Refine Media
- (new) Tags -> Refine Tags; Category -> toolbar category select
- Sort select (Newest/Oldest/Filename A–Z/Z–A) -> toolbar sort
  (Recently added/Oldest first/Name A–Z/Name Z–A, plus Highest rated)
- Layout buttons -> .segmented Grid/List/Masonry in the filter row
- Thumbnail size field -> label.small + select#image-size
- "Group by positive prompt" link -> button.chip "Group by positive
  prompt →" (#/library)
- Refresh -> Import -> "Scan an output folder" -> Rescan (D15)
- "Current gallery" link -> removed; Overview's tool cards link the
  legacy gallery (D29)
- "All images" h2 + count -> .section-caption #result-count
- .image-continuation -> .infinite-continuation (Retry kept)

Legacy behavior kept: video items, keyboard grid navigation, scroll and
filter restoration, bounded DOM, continuation retry, source-change
recovery (now disabling the toolbar, Filters and Import). The Filters
and Import dialogs close on route change and browser Back.

The shared toolbar (C1) called .some() on select.options, which is an
HTMLOptionsCollection in a browser (only the unit tests' fake DOM gave
an array); setValue and setCategories now spread it first. The layout
segmented control may wrap at extreme zoom (D31) instead of scrolling
the 390px page sideways.

The retired .image-filters, .image-view-controls, .image-toolbar,
.image-layouts, .image-collection, .image-continuation and duplicated
.image-workspace grid rules are gone from images.css.

* test(workspace): model select.options as a browser collection

The toolbar units' fake DOM returned select.options as an Array, so the
toolbar's .some() call passed there while it threw in every browser
(fixed in 12228e2). options is now an indexed, iterable, frozen
HTMLOptionsCollection-like object without Array methods; against the C1
toolbar three units fail with "sort.options.some is not a function".
The units also assert that an unknown sort falls back to the first
option and that a still-listed category survives a category refresh.

* fix(images): concept Load more focus, empty state and review rulings

Review of #249 C2:
- Load more and Retry focus the first appended image, as the concept
  does (app.js loadMoreImages). Focus moves without scrolling so the
  visible cards keep their place (bounded-DOM anchor), and only while
  focus is still on the pressed control: a viewer or a field the user
  moved to keeps it. When a last page adds nothing and the button hides,
  focus goes to the last image, never to the page; a failed page focuses
  Retry.
- The concept's empty state beside the grid: .empty "No matching
  images. Try another search or category."
- D17: the caption date shows only under Newest first (", YYYY" outside
  the current year) and is hidden under Oldest first and the name and
  rating sorts.
- en-US number grouping in the embedded count too.
- Refine "Folder" defaults to the concept's "All output folders". It is
  not narrowed by Source: the concept has no Source field, and the list
  holds every source's folders, so any pair can be chosen in one dialog.
- The shared toast moves to toast.mjs; library-editor re-exports it.
- The D32 overlay (extreme zoom) wraps the layout segmented control and
  the continuation row instead of pushing them out of the 390px column.
- The legacy gallery checks moved to Overview's "Open existing image
  gallery" card (D29).

Mapping additions: grid with no results -> .empty concept copy beside
the grid; Load more press -> focus the first new image (concept).

* feat(images): concept image cards; image opens the viewer

Cards are the concept media card: .media-art > button.image-open with the
image or video, and .media-info with the D10 title (linked title only when
the link is confirmed, else the filename; title attribute = path), the D11
subtitle "W x H · EXT · Seed N" (seed shown as the listing's string) and an
Inspect info button. The byte size and the Preview button are gone.

Activating the image (click, Enter, Space) opens the viewer and focus
returns to it on Escape; Info selects the image into the panel (D19).
Arrow, Home and End navigation, bounded rendering and scroll restoration
are kept.

Columns and gap now come from the concept grid's computed style
(gridMetrics) instead of a production breakpoint table, so the concept
breakpoints apply; a 120px minimum card bounds extreme zoom (D3, D31).
images.css card rules are retired into marked overlay rules. The scope
chip reads "All output folders" when no folder is chosen (D14).

* fix(images): pair-linked cards take the positive's title; concept Info, alt and size

- Pair links title the card, inspector and viewer from the linked positive's
  Library title or confirmed first line, never the pair title (the negative
  variant's name). The card now equals the concept card, title included.
- Info drops aria-pressed; the card's .selected carries selection, as in the
  concept.
- Card images take the card title as alt and the listing's width/height.
- One shared minimum card width for layout and the narrow-card class.
- A debounced search that lands after a sort change no longer reloads page 1.
- 200% zoom test also runs at an 800px viewport and device scale 2.

Refs #249

* feat(images): concept hover zoom

* feat(images): favorite images from the card

* feat(images): concept viewer shell

* fix(images): one favorite request per file across leaving and returning

The pending set and the receiver of each answer now live at module level
beside the saved visit. A press on a returned Images page waits for the
request the earlier page sent, and its success or rollback reaches the
card on screen, or the saved visit when no Images page is mounted.

* fix(images): mark a star busy while its favorite request is pending

The star carries aria-disabled="true" until the answer arrives, also on a
card rendered after returning to Images, so the ignored second press is
announced as unavailable instead of doing nothing silently.

* perf(images): repaint one card on a favorite toggle instead of the collection

stream.updateItem tells onChange which item it patched, and the Images
page then skips the full layout pass (computed style, grid metrics and
layout over every loaded item) because no card changed size or place.

* docs(images): say the lone-star overlay rule is interim until the checkbox PR

* test(images): concept comparison for the Images page

images-concept.spec.mjs compares the production Images page with the
immutable concept on the concept-shaped fixture: heading, toolbar, filter
row, caption, grid, the first six cards, continuation and panel box in
Grid, List and Masonry, at 1600x1100 and 390x844, dark and light, normal
and reduced motion; plus hover zoom, the viewer title, focus restore,
Info selection, search and Load more with its toast. mapImagesConcept
applies only recorded deviations (D3, D8 and D16 follow-up PRs, D9, D10,
D14, D15, D17, D29, D31, D32); removed row items flow again from the
concept's own boxes. A mutation check proves the comparison fails on a
.media-info padding change and on removed Inspect buttons.

Corrections the comparison demonstrated:
- the grid and continuation carry the concept's ids (image-grid,
  gallery-continuation);
- bounded rendering reserves the card footer at its fractional height:
  offsetHeight rounded a wrapped 67.5px row up, so at 390px every card
  was a pixel taller and the grid 6px taller than the concept.

Two mismatches wait for a ruling and are pinned on both sides: the
category order (concept prompt order, production alphabetical) and the
singular count line ("1 positive prompt").

* fix(images): ignore sub-pixel noise when the measured card footer grows

Under page zoom the fractional footer height (rect / scale) can carry float
noise above the stored value, which relaid the grid and scrolled for nothing.

* test(images): map D35-D37 and widen the Images concept comparison

- D35 (singular counts) and D36 (alphabetical categories) are mapped
  deviations instead of pinned values; a search for exactly one image
  checks both singular nouns, and a one-image Load more checks the toast
  and continuation.
- Grid, cards and panel at 1200x900 (2 columns, 275px panel) and
  1800x1100 (4 columns); Large and Compact join the layout loop.
- Masonry tops and heights compare unrounded within one layout unit per
  card above in the column (D3), not as rounded integers.
- The Save view and sample-tag re-layout is checked against the concept
  page with those items removed at runtime.
- The Load more row is compared before the press in both documents (D37).
- Two seedless fixture images (seedless_images on the concept reset)
  prove every row reserves the tallest footer (D3).
- D10 paths come from the fixture manifest; the D8 star margin is mapped;
  list-row derivations cite D3 and D9.

* fix(images): search never matches a pair's own variant title

Search covers the filename, the linked positive's text and Library title,
and its tags (#249 D12). The pair title names a negative variant, so a
variant name alone no longer admits the positive's images.

* fix(viewer): keep the Media controls group and drop a stale position description

The concept viewer toolbar is again a named group (#249 D34 #8), and
Previous/Next are described by the position line only while a single
item is loaded.

* docs(history): drop the remaining-polish notes the concept viewer and cards closed

The viewer now takes the concept stage and toolbar, and Images cards open
the viewer, select into the panel and favorite. Only the select checkbox
(#249 D8 follow-up) and the tag button (#249 D9) stay outside #209.

* test(images): the singular copy when scrolling loads one image; countCopy in the shared inventory helper

* refactor(images): retire the replaced images.css rules and lint the kept page sheets

The unused .image-facts rules go. The three prompt-block overrides on
concept selectors are marked #249 D22 interim until PR D deletes the
file, and every kept page stylesheet now passes the overlay lint.

* test(images): the extreme-zoom anchor probe reads the fractional footer measurement

Since the concept comparison (#249 C7) the grid measures footers with
getBoundingClientRect, so the probe that hooked offsetHeight never fired.

* test(images): cover the masonry fallback when a finite size underflows its ratio

* docs(images): concept Images page and recorded deviations

TESTING.md documents the Images page controls, the recorded #249
deviations and how the earlier workspace filters moved, and notes that
every kept page stylesheet is linted. The frontend coverage baseline
adds the toast, toolbar and workspace dialog modules.

* fix(images): show that Import → Rescan runs, and how it ended

The old Refresh button disabled itself while /gallery/rescan ran; Import →
Rescan closed its dialog and showed nothing. Now a toast says the rescan
started ("Rescanning output folders…") and how it ended ("Rescan finished.
N files in M output folders.", from the scan's own total and roots, or
"Rescan could not finish. Try again."). A reopened Import dialog shows
Rescan as "Rescanning…" and aria-disabled, and ignores a press on it.

Failing before: the held-rescan browser test saw no toast and no busy
state; the dialog unit test found no "Rescanning…" button.

* fix(images): rewrite the live status only when its text changes

A star press republishes the stream, and the textContent setter replaced
the continuation status, the count and the caption date with the same
text, so a polite role="status" region could announce it again. Text and
the role attribute are now written only when they change.

Failing before: a MutationObserver saw 10 records on those nodes after a
favorite toggle; now none.

* fix(images): Refine's Folder list follows the chosen Source

The workspace subfolder discovery returned only the union of every
source's folders, so "Source: temp" could be paired with a folder that
exists only under output and silently match nothing. In workspace mode
each root now lists its own folders, and "Refine this view" offers only
the chosen Source's folders: changing Source keeps a folder it also has
and otherwise returns Folder to "All output folders", as the old field
row did when it reloaded a Source's folders.

Failing before: the API test found no per-root folders (KeyError), the
dialog unit test and the browser test still offered output-only folders
under the input source.

* fix(images): an unchanged Refine apply keeps the loaded page

"Apply filters" (and "Clear filters" with nothing set) reset the stream,
refetched the first page and dropped the reader's scroll position even
when no filter changed. Like the toolbar's equality guard, an unchanged
refinement now does nothing.

Failing before: the browser test counted one listing fetch after an
unchanged Apply; now none, and the loaded cards stay in place.

* fix(images): one discovery alert when sources and categories both fail

With the server unreachable on load, the sources and the categories
status paragraphs each fired role="alert" back to back. The page now
keeps one alert, written once both first loads settled, naming whatever
failed ("Sources, folders and categories could not be loaded. Rescan
from Import to retry." when both did).

Failing before: getByRole('alert') resolved to two elements.

* fix(images): sort toolbar categories alphabetically on the client

D36 promises alphabetical real categories, which held only while the
server sorted them and a restored category was among its list. The page
now sorts them case-insensitively itself, a restored category included.

Failing before: the toolbar listed a server's [zeta, Alpha, beta] in
that order.

* refactor(db): drop the unused pair title from the link summary query

_link_summary has not read the pair's own title since the card took the
linked positive's Library title (D10), so the query no longer selects
it; the row indexes and the stale comment are updated. All values stay
bound.

Failing before: the summary SQL still selected pp.title.

* test(images): end negative checks on real signals, not fixed sleeps

The "no second request" checks waited a fixed 150 ms or 1000 ms and the
hover probe 450 ms, which can pass vacuously on a slow Firefox or WebKit
run. They now read the fetches the page's code started (the API client
calls fetch in the same task as the press), wait for a timer of the
toolbar's own debounce delay set after the debounced report, and wait
for the hover transition to finish.

Checked by mutation: without the favorite pending guard or the toolbar
equality guard, the rewritten checks fail.

* docs(testing): record the Rescan feedback, Source-bound folders and category sort

The Images contract now names the Rescan toasts and busy state (D15),
the Folder list that follows Source and the no-op unchanged refinement
(D14), and the page-side category sort (D36).

* test(viewer): escape every regex metacharacter in the filename match

* fix(viewer): title a linked card at once instead of waiting for its inspection

On a slow runner the viewer showed only the filename until the inspection
answered, while the concept titles it immediately. The card's linked
title is already loaded, so the header uses it on open.

The favorite test now waits for the first request to settle before the
Space press, which the one-request-per-file guard otherwise ignores.

* fix(images): cap the search at the listing API's 500 characters

A longer paste was sent as is, the API rejected it and the collection
showed "Images could not be loaded". The legacy search capped it.
2026-10-20 11:31:53 -07:00
Vito 6c82098207 feat(images): data for the concept cards — linked titles, seed, search and filters, counts, favorites (#257)
* feat(images): link summary on workspace image items

* fix(images): carry title source and cut first-line titles like the Library

Summaries gain title_source (stored or text). The inspector always shows a
stored title and confirms only a first-line title against the file, so a
stored title equal to the first line is no longer withheld. A link-title read
failure no longer marks a readable link unavailable. First-line titles over
80 characters end with an ellipsis, as the Library's title() does. The card
gate for unconfirmed first-line titles is pinned as an expected failure for B2.

* feat(images): unambiguous seed on workspace items

Workspace items gain `seed`, read with the shared generation-parameter
parser from the prompt/workflow records the header open already loaded
(each at most 2 MiB; no extra file reads, videos give None). A
placeholder or non-integer seed gives None. JSON integers beyond 2**53
are recovered exactly, so the card prints the digits the inspector shows.

The same parse confirms first-line card titles: a `text` title shows only
when the file's parsed positive gives the same title. One helper applies
that rule on the card and in the inspector, which now reads the positive
from its own inspection records with the same parser.

* fix(images): a deeply nested record no longer fails the workspace listing

The Python parser recurses over nested arrays where JavaScript String()
does not, so a crafted 2 KB PNG turned every workspace page holding it
(and its inspect route) into a 500. Card facts now treat RecursionError
like any record the parser cannot use: no seed and no confirmed title.

Also: Library previews skip the card parse they never show; the parse
runs after the file handle closes; the exact-seed read uses the one JSON
reader (parse_js_json(..., exact_integers=True)) and the public
number_string; record size is decided from the text length unless only
the UTF-8 byte count can tell.

* feat(images): search and filter workspace images by linked prompts

The workspace listing now takes category (exact), tags (comma list, all
required) and sort=rating, and q also matches the linked positive's text,
Library title, pair title and tag names. One bounded, parameterized SELECT
(get_image_link_matches / get_image_paths_matching) returns normalized
paths of unambiguously linked images; the listing is intersected with it.
Unlinked and ambiguous files are excluded by a category or tag filter and
sort last by rating, with a newest-first tie-break. The cursor scope covers
the new filters and sort; unknown values are still rejected.

The derived listing values and sort orders are cached per gallery scan so a
50,000-file listing with 20,000 links pages well under 150 ms at p95.

* test(images): count the filter budget everywhere, time it in one job

The 150 ms wall-clock budget failed under the CI coverage tracer, so it
is split. test_filter_work_is_bounded runs on every job and under
coverage: one filter SELECT per request, the ambiguity probe is an index
search (EXPLAIN QUERY PLAN), the per-scan values are built on the cold
request only, and a cursor page calls the sort key at most
ceil(log2 n)+3 times. test_filter_budget runs only with
PROMPTMANAGER_PERF_BUDGET=1 and no tracer, asserts the best of three
rounds' p95, and has its own non-required "perf budget" job. TESTING.md
records the convention.

* fix(images): rating sort works however many link rows the database holds

The filter SELECT counted every generated_images row against a 500,000
row cap and materialized all of them first, so a large history made
sort=rating answer 400 with nothing to narrow. The rows are now streamed
and only paths in the listing's normalized set are kept, so memory is
bounded by the listing; the cap and LinkFilterTooBroad are gone.

* fix(images): rebuild workspace listing values only where the scan changed

A TTL rescan that finds the same files reuses the previous values and
sort orders; one that finds a few changes rebuilds only those files.
Gallery invalidation and a gallery-roots change drop the derived values,
so old roots no longer hold memory. category is trimmed like tags.

* fix(images): a monitor invalidation rebuilds only the new files' values

The image monitor invalidates the gallery whenever it sees a new file,
and that dropped every derived workspace value, so the next request paid
a full cold build. Invalidation now keeps the per-file values and drops
only the sort orders; the rescan reuses every unchanged file and builds
only new or changed ones. A gallery-roots change still drops everything.

* feat(library): expose run count, last use and variant totals

* fix(library): an out-of-range last use reads as unknown, not a failed list

* perf(images): read positive counts only on a plain listing's first page

* feat(images): persist image favorites

Favorites are stored in image_favorites keyed by the gallery root label
and the relative path, set and cleared through PUT/DELETE
/prompt_manager/images/favorite with a {root_index, path} body validated
against the rooted listing. Workspace image cards carry favorite: bool.
The table is created by migration, travels with backups, is optional in
old backups and required after a restore's migration.

* fix(images): key favorites by the real root and case-normalized path

Folder names are not unique across gallery roots, so two roots shown as
"output" shared each other's stars. Favorites are now keyed by the
root's real, case-normalized path (server-side only) and a
case-normalized relative path, so a Windows case-only rename keeps its
star while distinct Linux files stay distinct. Toggles look the file up
in a per-scan dictionary instead of walking the listing.

* fix(images): favorites lookup follows monitor invalidation and the stored key bound

A monitor invalidation now also drops the favorites lookup, which held the
previous scan list until the next toggle. PUT refuses a favorite whose root
or relative key would exceed the schema's 4096-character bound, so the live
database keeps passing startup and restore validation.

* docs(testing): workspace data fields for the concept pages

* fix(images): card facts read the inspector's projected graphs

The inspector hands its browser parser only graphs the inspection
projection parsed (strict JSON, depth 64, node ceiling); a deeper record
is "limited" there, so the legacy fill never runs and no seed shows. The
card parsed the raw records instead and showed a seed the inspector did
not, e.g. for a 900-deep class_type beside a normal sampler.

project_graph() is now the one projection step, used by the inspection
response and by the card. The card runs the shared parser on the
projected graphs, the same values the browser receives: big integers
arrive as their exact digits, so the separate exact-integer re-parse and
the parse_js_json flag it needed are gone.

* fix(images): send the card seed as a decimal string

JSON.parse rounds a number past 2**53, so a listing seed such as
9223372036854788153 reached the page as 9223372036854788000 while the
inspector showed the file's digits. Every non-null card seed is now its
decimal string.

The Node parity harness now JSON.parses the real listing response and
compares String(seed) with the seed the inspector shows, including a
seed past 2**53. TESTING.md describes the string contract and drops the
removed exact re-read.

* fix(images): bound digit-string seeds and isolate each card's parse

A seed recorded as a 5000-digit string reached int(), which raises
ValueError past Python's integer string limit, and the listing answered
500. A digit-string seed now has at most 20 digits (2**64 fits), and the
conversion falls back to no seed on ValueError.

Each card's parse is also guarded in the page builder: an unexpected
error in one file's records logs a warning and gives that card no seed
and no first-line title, and the rest of the page still lists.

* fix(images): filename search survives a failed prompt-link read

On main a workspace q matched only file paths, so search worked with a
broken database. Since search also matches linked prompts, any q turned
a failed link read into a 500. A q without category, tags or the rating
sort now falls back to filename matches with a null positive_count and
logs the failure with its traceback; those linked filters still fail.

* fix(images): card reads PNG records with the inspector's reader

The card read PNG text through Pillow's header info, where a later
duplicate prompt chunk silently wins and text after IDAT is missed. The
inspector's bounded reader refuses conflicting duplicates and reads text
after IDAT, so a file with two different prompt chunks showed a card
seed the inspector refused.

PNG cards now read their records with read_inspection_file, the reader
the inspector uses. When it refuses a file the card keeps its
dimensions from Pillow and shows no seed. Other formats still use
Pillow's header info, as the inspector's header read does.

* perf(images): read each card's header facts once per gallery scan

Every page parsed each card's prompt and workflow records again, about
3.4 ms per 50 KB record set and 25 ms per 400 KB workflow, inside
ComfyUI's server process, on every scroll-back and refresh.

Dimensions, the seed and the parsed positive's display title are now
cached per root by (path, mtime), beside the scan's per-file values and
invalidated the same way: a rescan keeps unchanged files and prunes the
rest, a roots change drops them all, and only files of the current scan
are stored. An unreadable header is not stored. Links and favorites
are still read on every page.

* ci: run the perf budget on main pushes and on demand only

The wall-clock budget has under 2x margin on shared ubuntu runners, and
a red exact-head check blocks a merge that must never be rerun to green.
The job now runs on each push to main and on workflow_dispatch, not on
pull requests. TESTING.md says a red run on main means fix the code.

* fix(library): cut display titles at 80 UTF-16 units, never inside a pair

The server counted code points while the Library's JavaScript counts UTF-16
units, so a first line of emoji kept 80 emoji on cards but showed 40 in the
Library. Both now cut at 80 UTF-16 units and drop a character that would
straddle the limit instead of leaving half a surrogate pair.
2026-10-20 00:18:32 -07:00
Vito 9d398f9862 feat(workspace): concept shell — sidebar, topbar, heading and footer on the approved mock's CSS (#256)
* build(workspace): vendor the approved concept stylesheet with a drift check

* refactor(workspace): adopt the concept stylesheet as the workspace base

* feat(workspace): concept sidebar with real navigation counts

* feat(workspace): concept topbar with Theme and ComfyUI link

* refactor(workspace): one concept heading and footer for every route

* fix(workspace): keep the canvas link on the proxy mount

A resolver failure no longer stops the shell from mounting: the ComfyUI link falls back to the root read from the page's own mount path (never another origin). Tests cover a non-standard /ComfyBackendDirect mount, including that no request escapes it.

* test(workspace): concept fixture and shell comparison

* test(workspace): concept fixture carries the concept inspector facts

* fix(workspace): match the concept brand name and compare shell hover states

* test(workspace): shell comparison mutation check and docs

Add mutate() to the shared concept inventory and a shell mutation check: a
nav-row min-height rule and a removed .breadcrumbs each make the comparison
fail with the changed path. Harden the resolver-failure guard test with an
injection count, make the overlay lint fail when workspace.css is missing,
mark the library.css .row-actions leftovers for PR E, and document the
concept CSS base, the overlay lint, the comparison method and the recorded
shell deviations in TESTING.md.

* fix(workspace): focus the page title on a route change

A sidebar or history navigation now focuses #page-title, as the concept
does on every hashchange (app.js 1168), so a screen reader announces the
page heading instead of the main region. The first render leaves focus
alone and the skip link still focuses main. The heading module's comment
now describes what the shell does.

* fix(workspace): give the Overview nav entry its own home glyph

Overview reused the concept's Activity pulse byte for byte, so once the
Activity row lands two entries would share one icon. Overview now has an
outline home glyph. The shell comparison takes it from the D28 ruling
instead of the concept's activity path, and the icon test checks that
every approved glyph is distinct.

* test(workspace): accept only #249 decision markers and state the lint scope

The overlay lint accepted any D-number, so another issue's ruling such
as "D5 (#245)" could mark a concept override. A marker now has to be a
D-number with no other issue cited; the vocabulary rule that cited #245
reads "#245 decision 5". TESTING.md now states that the lint covers
workspace.css only, lists the unmarked overrides still in images.css,
and says PRs C and D run the lint over each page sheet they keep.

* perf(workspace): re-read only Library and Tags counts after a Library write

A confirmed Library or vocabulary write cannot change the Images count,
but it re-read all three, including the Images output listing. It now
re-reads only the Library and Tags counts. Each count has its own read,
so a write never cancels an Images read that is still in flight.
2026-10-19 23:04:40 -07:00
Vito a44cd27c2e fix(parse): build array String() without recursion (#255)
utils.generation_params converted nested arrays to strings recursively,
so a crafted file with a value nested about 900 arrays deep raised
RecursionError, where the browser parser returns a result. The join now
runs on an explicit stack. A JS-parity case with 900-deep values pins it.
2026-10-19 22:04:32 -07:00
Vito 673433b2ae fix(inspect): keep the server result when the legacy parser throws (#254)
#251 fills undecided roles from the browser parser, which throws on some
malformed graphs (for example a null workflow node). The error escaped
the record builder, so the inspector could not load such files at all.
The fill-in is now skipped when the parser throws.
2026-10-19 21:43:01 -07:00
Vito 36564b4767 feat(parse): one generation-parameter parser in Python; scan links what the resolver cannot decide (#253)
* feat(parse): port the browser generation-parameter parser to Python

Adds utils/generation_params.py, a line-for-line port of
extractGenerationParams in web/js/comfy-metadata.js, the parser behind
the gallery, admin and metadata pages. The scan and live monitor can use
it so every surface reads prompts the same way. The JavaScript parser is
unchanged.

The port reproduces the JavaScript value semantics the result depends on
(property order, truthiness, String(), trim(), strict equality, Map keys,
JSON.parse number rounding, and the inputs on which it throws), and runs
its recursive tracing on an explicit stack so crafted graphs cannot
exhaust the Python stack.

Tests:
- behaviour tests for each tracing rule, with JavaScript results as
  expected values, including the visit-limit boundary;
- golden vectors of the JavaScript parser's results for every named case
  and every PNG fixture, so the comparison runs without Node;
- a live comparison against the JavaScript parser when Node is present;
- a randomized differential test over 9,000 generated graphs and
  workflows, coherent and corrupted, including inputs where the
  JavaScript throws.

Five deliberate porting mistakes (trim, truthiness, prepend handling,
visit limit, pair slot) each fail the tests. On a 38,602-file output
folder, all 18,491 files with a workflow give identical results.

* fix(scan): link the traced prompt when the pair resolver cannot decide

The output scan skipped every file the pair resolver called ambiguous or
unresolved, so workflows where several samplers share one prompt (a base
pass, an upscale pass and a detailer) never reached the Library from a
scan. It now links the positive prompt the shared parser traces for
those files, the prompt the inspector and the legacy pages show. Files
the resolver decides are stored as before, and graphs without a
recognized sampler keep the legacy extractor (#252).

Read-only scans of a 38,602-file output folder into temporary databases:
- links: 1,500 before, 7,954 after; none of the 1,500 dropped or changed;
- of the added links that differ from the pre-redesign scan, 1,275
  replace a number, a LoRA tag, a fragment or a truncated prompt that
  scan stored.

* refactor(parse): return a uniform pair from the SameValueZero key
2026-10-19 20:56:51 -07:00
Vito d6eba05d21 fix(inspect): show the prompts and settings the legacy parser finds (#251)
* fix(inspect): show the prompts and settings the legacy parser finds

The workspace inspector took its prompts from the new server resolver,
which refused workflows where several samplers share one prompt (base
pass, upscale pass, FaceDetailer), and showed "Multiple possible
prompts" for files the legacy gallery and admin pages read correctly.

The inspector now runs the same extractGenerationParams those pages
use and shows its positive, negative, seed, steps, CFG, sampler and
model whenever it finds them. Prompts recorded at execution or saved
in the Library are never replaced; when the legacy parser finds
nothing, the server result stays.

Read-only check over a 38,602-file output folder: prompts shown went
from 1,798 to 7,542 files, and every one of the 7,236 positives and
9,421 negatives the legacy parser finds is shown identically.

* fix(inspect): keep definite server prompts over the legacy parse

The legacy parser follows ConditioningZeroOut back to the positive text
node and shows the positive as the negative. Apply its result only to
roles and settings the server could not decide, so a definite answer
such as an explicitly empty negative is never replaced.

Node callers (the corpus facts tool and the parity test support) now
pass the parser explicitly instead of relying on a browser global.

On a 38,602-file output folder, prompts shown stay at 7,542 files. Where
the server and the legacy parser both answer, they differ only in
surrounding whitespace (793 roles), except 227 zeroed negatives the
legacy parser misread and one prompt that is a JSON block.

* fix(inspect): never fill a setting the file does not record

The legacy parser can take a FaceDetailer as the generation sampler and
report its CFG for a workflow whose guider records none. Fill a setting
from the legacy parse only when the server left it unresolved or
ambiguous; a known value or a definite "not recorded" stays.

The corpus and parity Node tools again measure extractGenerationFacts
on its own, the contract their fixture vectors describe.

* fix(viewer): destroy an inline Viewer only after its pending view settles

Viewer.js's inline destroy() only unbinds and leaves an in-flight image
view running; when it finishes it reads the destroyed item list and
throws "c is undefined". Closing the preview while an image was still
loading hit this in Firefox about once in 15 runs of the recorded-run
history test. A closed viewer is now destroyed once that view settles,
its image fails, or a 10 second bound passes.

The history test passed 60 of 60 runs in Firefox with the fix.

* fix(viewer): keep the image src until a closed Viewer is released

Viewer.js rebuilds its item list from images that still have a src. On
close, the src was removed before the viewer cleanup ran, so a pending
view found no items and threw "c is undefined" (WebKit and Firefox).
With Viewer.js, the src is now removed after the viewer is destroyed,
once its pending view settles.

The recorded-run history test passed 40 of 40 runs in WebKit and in
Firefox.
2026-10-19 20:05:55 -07:00
Vito 6d2213ead5 feat(inspect): declarative node support matrix (#250)
* feat(inspect): add a declarative inspection node semantics table

* fix(inspect): bound and isolate the semantics table

Bound the semantics loader: at most 1 MiB is read, containers may nest
at most eight levels, and a RecursionError from the JSON parser becomes
a SemanticsError instead of escaping.

The table is no longer loaded at import. get_semantics() reads it on
first use and caches only a valid result, and the resolver imports it
lazily, only for a profile with semantics on. The default ingestion
profile never imports the module, so a missing or corrupt table cannot
break scan, monitor or admin import. Inspection of a prompt graph with
an unusable table is unresolved with the reason "Inspection node
semantics are unavailable" and an error log, rather than a crash.

INSPECTION_PROFILE now has semantics on; no strategy consumes the table
yet, so resolution is unchanged.

* feat(inspect): resolve guider-based samplers

Under INSPECTION_PROFILE the resolver discovers samplers from the
semantics table and follows SamplerCustomAdvanced through its guider
link (Reroutes allowed) to a listed guider:

- CFGGuider: positive and negative, so the pair resolves.
- BasicGuider: positive only. The state is "absent" with the reason
  "Guider has no negative input", the existing missing-negative path.
- Any other guider, including DualCFGGuider (D17), is unsupported-node
  and names that guider.

Guider hops draw visits, scan and depth from the existing budgets. A
SamplerCustomAdvanced upstream in latent ancestry is an earlier stage,
so the result stays multistage-ambiguous. Positive and negative
provenance record guider_id. FaceDetailer (detailer-stage) is not a
candidate yet: deciding it needs output ancestry. DEFAULT_PROFILE never
loads the table and is unchanged.

Vector changes (inspection profile only, default vectors unchanged):
- f07b: no-sampler -> resolved (CFGGuider).
- f17: no-sampler -> unsupported-node (FluxGuidance, Task 2.3).
- f01b: no-sampler -> unsupported-node (EmptyFlux2LatentImage latent,
  Task 2.5).
- f01: no-sampler -> ambiguous (24 SamplerCustomAdvanced branches).

The no-sampler tests (resolver and inspect route) now use a sampler
class the inspector does not know, because f17 has a supported one.

* feat(inspect): trace conditioning through supported pass-through and selector nodes

* feat(inspect): treat detailers as sampler stages

* fix(inspect): choose selectors that share one condition together

* fix(inspect): bound detailer stage chains by the depth limit

* feat(inspect): support common text encoders and string sources

* fix(inspect): keep pair execution evidence behind unreadable string sources

* feat(inspect): accept any non-sampler latent origin

* fix(inspect): queue each node once in breadth-first ancestry walks

The latent and detailer image walks queued every link before checking
whether its node was already seen, and popped from the list head. A dense
shared fan-in grew the queue far past the visit budget before any visit
was charged. Both walks now use a deque and enqueue each node at most
once, charging every examined edge as scanned work; outcomes are
unchanged.

* fix(inspect): treat unlisted pipe and embed samplers as possible stages

The latent and detailer ancestry walks only flagged unlisted nodes with a
linked latent_image, guider, positive or negative input, so easy-use pipe
samplers, Impact pipe detailers and embed-driven video samplers were walked
through and the final sampler's prompt resolved silently. A separate
stage-shaped input set (adding pipes, embeds and custom-sampler parts)
now makes such nodes unsupported-node, named.

* fix(inspect): walk each sampler's latent ancestry once per resolution

Every selector branch run and candidate re-walked the same latent
ancestry, so conditioning-carrying origins such as WanImageToVideo turned
a three-selector graph from ambiguous into work-limit. The walk outcome is
now cached per sampler; branch runs of one candidate pay for it once and
other candidates are charged its visit cost, as the text memo does. Walks
that ran out of work are not cached. Every link input is still walked.

* fix(inspect): treat unlisted model-driven samplers as possible stages

Unlisted samplers that link a model with conditioning or a latent under
other input names (XlabsSampler, generic video samplers) were still walked
through, so the final sampler's prompt resolved silently. An unlisted node
with a linked model plus conditioning, neg_conditioning, samples or latent
is now unsupported-node, named; model patches such as LoraLoader and
ModelSampling nodes are not.

* feat(inspect): compute facts from the resolved sampler

* fix(inspect): merge facts across sampler branches when ambiguous

* fix(inspect): merge facts over unresolved stages and share stage inputs via the table

* feat(inspect): explain why a prompt could not be resolved

* feat(inspect): port legacy parser rules the new resolver missed (PromptManager)

R1: before 3.2.3 a save patch overwrote every PromptManager node's saved
text with the last-run node's prompt. The legacy viewer recognizes the
signature (comfy-metadata.js createContext: two or more PromptManager
workflow nodes with one API text and different widget values). Inspection
now refuses those nodes as malformed instead of resolving to another
node's prompt; the inspector hands the parsed workflow to the resolver for
this check only. Lands before the new pass-throughs (R6) that would
otherwise make these graphs resolve to the wrong text.

R7: this repository's node classes up to 07a5af3 (PromptManagerV2,
Positive, Negative, Detailer, V2Text, PositiveText, NegativeText) resolve
with their own outputs and their exact strip and ", " join.

The default (ingestion) profile ignores both.

* feat(inspect): port legacy parser rules the new resolver missed (A1111)

R3: an accumulating batch saver repeats the A1111 parameters record once
per batch item, identical except "Batch index: N". The bounded PNG reader
now keeps the last copy (Pillow's rule, which #165 relied on) instead of
calling the file unreadable. Any other difference, including a missing or
second batch index, is still a conflict, and decoding the copies draws from
the duplicate role budget.

R4: parameters text that starts with "Negative prompt:" has an empty
positive (#165), instead of showing the delimiter line as the positive.

R5: the settings footer may be followed by sd-dynamic-prompts' one-line
"Template:" and "Negative Template:" records, and may be "Steps: N" alone
when there is no full footer.

R10: commas inside A1111's quoted settings values no longer split them, so
an "ADetailer prompt" with a comma keeps the other facts known.

The facts' A1111 reader mirrors R4, R5 and R10; the default (ingestion)
profile keeps the historical split. Also from the Task 2.7 review: a guard
test fails on any uncoded unresolved reason without a user line (M1), and
the encoder bounds role lines to the client's 512 code units (M4).

* feat(inspect): port legacy parser rules the new resolver missed (installed nodes)

R6 and R9 for nodes whose sources are installed beside ComfyUI, each
entry's input names and output slots read from the node's code (the task
report lists file and line):

- detailer stages UltimateSDUpscale (image), UltimateSDUpscaleNoUpscale
  (upscaled_image) and DetailerForEach (image), judged with their upstream
  samplers like FaceDetailer;
- pass-throughs ControlNetInpaintingAliMamaApply and LTXVConditioning
  (positive 0, negative 1) and FluxKontextMultiReferenceLatentMethod;
- encoder Text to Conditioning, strings TextInput and DisplayText;
- facts model pass-throughs Lora Loader (LoraManager), Power Lora Loader
  (rgthree), ModelPatchTorchSettings, CFGNorm, PerturbedAttentionGuidance.

Impact Pack encodes a detailer's non-empty wildcard instead of its positive
input, so FaceDetailer and DetailerForEach now list it as a prompt
override: a literal wildcard is decided at run time and a linked one names
its source, instead of resolving to the positive input. The overwritten
PromptManager refusal (R1) still holds behind the new pass-throughs. The
default profile does not read the table.

* feat(inspect): port legacy parser rules the new resolver missed (upstream nodes)

R6 and R9 for nodes not installed beside ComfyUI here. Their input names
and output slots come from each pack's source at the commit the saved
workflows record in the node's cnr_id and ver properties (the task report
lists repository, commit, file and line):

- samplers ClownsharKSampler_Beta (RES4LYF), KSampler (Efficient) and
  ttN KSampler_v2 (latent input "latent");
- pass-throughs SeedVarianceEnhancer and ApplyInstantIDAdvanced (positive
  1, negative 2; output 0 is the model);
- selector Switch conditioning [Crystools];
- encoder smZ CLIPTextEncode: its text when with_SDXL is false; when true
  the connected CLIP decides at run time which inputs are read, so the
  prompt is known only when text, text_g and text_l agree;
- strings ttN text, CR Text, CR Prompt Text and TextBoxMira;
- facts model pass-through CR Apply LoRA Stack.

Inputs that replace what a sampler reads are not followed: RES4LYF options
(may carry a guider, and with sigmas replace the settings), an Efficiency
script and a ttN XY plot leave the prompt unsupported and the facts
unresolved. The overwritten PromptManager refusal (R1) still holds behind
SeedVarianceEnhancer. The default profile does not read the table.

* test(inspect): cover batch copies past the role budget and orphan widgets

* fix(inspect): match the batch index only in the settings footer

Review I-1: duplicate A1111 parameters copies were compared after blanking
"Batch index: N" anywhere in the record, so copies whose prompts differ
only by that phrase were accepted. The field is now blanked only inside
the record's one "Steps: N, ..." footer line, which must hold it exactly
once; any other difference stays a conflict.

Review M-4: the inspector parses the workflow record only when the API
graph can show the overwritten-PromptManager signature (a PromptManager
class, or a node with PromptManager's search_text input).

* fix(inspect): refuse overwritten PromptManager text inside subgraphs

Controller ruling on review M-1: PromptManager nodes inside subgraphs run
as "<instance>:<inner>" API IDs, nested instances chaining their IDs. The
overwritten-text check now walks subgraph instances (bounded in depth and
nodes) and reads a text widget stored by name, so these nodes are refused
like top-level ones, anchored or not. A definition without an instance
runs nothing and is ignored.

Review M-5: the historical adapters skip an explicit null prepend or append,
as the 07a5af3 code's "if prepend_text:" did.

* test(inspect): import unittest one way in the inspection suites

Code scanning flagged the mixed 'import unittest' and 'from unittest import
mock' forms. Use 'import unittest' plus 'import unittest.mock' everywhere.

* fix(history): name the recorded run's current-file prompts apart from its frozen fields

Now that the inspector resolves more saved files, the Recorded run dialog can
hold two regions named "Positive prompt" (and two of each Copy/Send action):
the frozen run field and the inline current file's prompt. Assistive
technology could not tell them apart, and the real-host chronology test read
two <pre> elements.

Prompt blocks take an optional context label. The inline current file inside
a recorded run names its regions "Current file positive prompt" and its
actions "Copy/Send current file positive prompt"; the visible headings stay
as in the concept. The real-host test now also scopes its frozen-field reads
to the "Frozen run prompt texts" section, and a disposable-server workflow
pins the distinct names with a resolved current file.

* fix(inspect): drop preview-only stages from unanchored candidates

A stage whose outputs reach only non-file outputs (PreviewImage and the other
non_file_outputs) cannot have written a saved file, yet it stayed an
unanchored candidate, so a preview-only FaceDetailer made the saved branch
ambiguous (f01b).

The inspection profile now drops such stages before detailer absorption,
using two linear backward passes over the graph (charged like the consumer
map; a failed pass keeps every stage). A stage without consumers, or one that
also reaches any other terminal, is kept, and nothing is dropped when every
stage is preview-only. Raw plus detailed double saves both write files and
stay ambiguous. A file in the host temp folder may itself be a preview, so it
is inspected with PREVIEW_INSPECTION_PROFILE, which keeps those stages.

Workspace facts follow the same choice: an absent or unresolved result that
names a sampler merges facts across that sampler's stages only, so the
dropped detailer's settings no longer blur the saved file's facts.

f01b now resolves (absent negative, guider has none); its vector is updated
and the expected failure removed. The default profile is unchanged:
differential against 39dcc40 identical over 160,256 cases. Over 60,054
inspection cases no previously resolved or absent result changed.

* fix(history): name the current file's prompt pair button apart from the frozen run's

The recorded run dialog nests the current-file inspector, whose footer repeated the "Send prompt pair" name. Name it from the prompt context, scope the real-host test to the frozen region, and add a dialog-wide duplicate-name check.
2026-10-19 17:26:37 -07:00
135 changed files with 25356 additions and 1356 deletions
+27
View File
@@ -8,6 +8,7 @@ on:
branches: [main]
push:
branches: [main]
workflow_dispatch:
# Superseded PR pushes cancel their older run. Every main push gets its own
# group (run_id), so no main commit's run is ever cancelled or dropped as pending.
@@ -146,6 +147,32 @@ jobs:
python -m coverage combine
python -m coverage report --fail-under=85
perf-budget:
# The reference wall-clock measurement (TESTING.md "Performance budgets").
# Shared-runner timing varies, so it never gates a pull request: it runs on
# each push to main and on demand. A red run on main means fix the code.
name: perf budget
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Workspace filter budget
env:
PROMPTMANAGER_PERF_BUDGET: "1"
run: python -m unittest -v tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget
lint:
name: black and flake8
runs-on: ubuntu-latest
+467 -5
View File
@@ -125,6 +125,37 @@ not certify every existing feature, arbitrary host versions,
GPU/model changes, cross-pack installation or visual design changes. Those still
need targeted validation.
## Performance budgets
A spec performance budget gets two tests:
- A structural test that always runs, on every OS/Python job and under the
coverage tracer. It asserts counts, never time: SELECTs per request, the
`EXPLAIN QUERY PLAN` of the hot query, cache builds on cold and warm requests,
and calls of the sort key per cursor page. Example:
`test_filter_work_is_bounded` in `tests/test_api_workspace_images.py`.
- A wall-clock test that runs only with `PROMPTMANAGER_PERF_BUDGET=1` and
without a tracer (`sys.gettrace()` or a `sys.monitoring` tool). It skips
otherwise. It warms up once, then measures three rounds and asserts the best
round's p95; the failure message lists every timing. Example:
`test_filter_budget` (Images filters, spec D12: p95 < 150 ms at 50,000 files
and 20,000 links).
The `perf budget` job in `.github/workflows/test.yml` is the reference
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
It runs on every push to `main` and on `workflow_dispatch`, never on pull
requests, because shared-runner timing must not block a merge. Dispatch it on a
branch to measure before merging.
Shared Windows and macOS runners do not run them; the structural tests cover
portability. Run it locally with
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
and quote the printed p95 in the PR body.
A red `perf budget` run on `main` means the code got slower: fix the code (or
change the budget in the spec with a reason) in a follow-up PR. Never rerun it to
green. It is not a required check, and it cannot be one while it skips pull
requests (`.github/branch-protection.json`).
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
@@ -196,7 +227,12 @@ The shared inline and collection inspector reads the current rooted file's suppo
embedded records, exact generation facts and recorded or inferred prompt evidence.
`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. The viewer never fetches another page
geometry, and the loaded-collection viewer. `viewer matches the concept shell and
toolbar order` measures the concept's `#viewer-dialog` shell, header copy, toolbar
order and frame geometry in the concept and in production at 1600 and 390 pixels;
its #249 D34 mapping keeps production's Download link, the appended position and
reason lines, coarse 40px targets and the toolbar inside `.viewer-left` (Viewer.js's
own `.viewer-toolbar` rule lifts the concept's across the dialog). The viewer never fetches another page
or preloads all originals. Its explicit slideshow waits 3.5 seconds after image
readiness, pauses at native video without advancing on `ended`, and resumes on a
manually selected image only while prior intent remains. Pause/Stop, inspector
@@ -227,7 +263,55 @@ workflow graph and its facts become limited). The serialized
response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt
graph, then all raw records atomically. Retry does not raise these limits. Ingestion
(scan, live monitor, admin import) and queue-time run counting keep the default
profile, including its 500-node gate.
profile, including its 500-node gate. Only `INSPECTION_PROFILE` reads the node
semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nesting
levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider`
link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider`
(positive only; the negative is `absent`, "Guider has no negative input"). Any
other guider, such as `DualCFGGuider`, is `unsupported-node`. Conditioning is
traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the
ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and
WanImageToVideo by output slot). `ConditioningZeroOut` is an explicit empty text
with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows that
branch only; with a linked boolean both branches are traced, equal texts resolve and
different texts make the sampler `ambiguous` with one candidate per choice (at most
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
part has the same text, otherwise `unresolved` with `composite` naming the node.
Results that depend on a forced choice are never memoized. A `FaceDetailer`
(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
different texts are `ambiguous` with `multistage-differs` and one candidate per
stage; an unreadable stage makes the chain `unresolved`. Unanchored, a sampler
whose output only feeds detailers (or previews) is not a candidate of its own.
A sampler's latent ancestry is walked through every link input of non-sampler
nodes within the same visit and depth budgets: with no earlier sampler it is a
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
naming it, here and in a detailer's image ancestry; a latent that is not a link or
reaches its own sampler is `malformed`; running out of budget is `work-limit` or
`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
selector branch runs share it and other candidates are charged its visit cost.
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
every part is identical or exactly one part is non-empty (exact `""`, whitespace is
text); otherwise `composite`. String sources reproduce the pinned KJNodes code from
literal settings only: `StringConstant` is its `string`; `StringConstantMultiline`
with a literal true `strip_newlines` is `replace("\n", "").strip()`;
`JoinStringMulti` keeps `string_1` (even empty), appends `string_2`..`string_N`
(`N` = `inputcount`) after the literal delimiter unless missing or exactly `""`, and
ignores later inputs. A linked setting is `unsupported-node`; a list output
(`return_list` true or linked) and `PromptSearchList` are `runtime-value` ("Text is
decided at run time"), never one list item. Unverified text nodes (LoRAfromText,
RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay `unsupported-node`. Every
intermediate text is length-checked and charged to the scanned-character budget. If
the table cannot be loaded, graph inspection is `unresolved` ("Inspection node
semantics are unavailable") and ingestion is unaffected, because it never imports the table.
`tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL]
[--summary TXT] [--code TREE]` measures a media folder read-only against the #240
@@ -257,6 +341,384 @@ Use the disposable fixture and managed-host prerequisites above. A maintainer
live check needs a ComfyUI restart after adding the Python route, then opening
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
### Concept shell and comparison method (#249)
**CSS base and drift check.** `web/js/workspace/concept.css` is a byte-identical copy
of the approved concept's `docs/concepts/workspace/style.css`. Never edit the copy;
it is only rewritten from the concept with `npm run --prefix tools/vendor sync:concept`.
`npm run --prefix tools/vendor check` runs `sync-concept-css.mjs --check`, which fails
on a missing copy or names the first differing byte. `workspace.html` loads, in order,
the toolkit controls, Viewer.js, `concept.css`, the page stylesheets that their own
PRs retire, and the production overlay `workspace.css` last.
`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no
other page or canvas script loads workspace CSS.
**Overlay decision-marker lint.** `lintOverlay` in `tools/vendor/sync-concept-css.mjs`
fails any `workspace.css` rule whose selector is also a concept selector unless the
rule carries a `/* D<n> */` comment, before its selector or inside its block. The
D-number must be one of this spec's (#249): a comment that cites any other issue, such
as `D5 (#245)`, is that issue's ruling and is not a marker, so write `#245 decision 5`
for those. The lint test fails if `workspace.css` is missing. The match is by exact
selector, so a narrower selector over a concept rule (for example
`.ca-tools dialog.prompt-sender`) passes the lint. Such rules, and the page-stylesheet
leftovers such as `library.css` `.row-actions`, name the PR that retires them, by
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.
**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
blessed production screenshots.
- The fixture is `tests/browser/workspace_concept_fixture.py`, reset through
`POST /__test__/concept/reset`. It holds the concept's six prompts and pairs, and
24 PNGs with real links, runs and the concept's inspector facts. The sidebar counts
are therefore the concept's own: 24, 6 and 15.
- The shared inventory is `tests/browser/concept-inventory.mjs`. One self-contained
in-page measurer runs the same concept selectors in the immutable concept
(`/__test__/canonical/workspace`) and in production. An absent element becomes
`null`, so the diff names its path (for example `chrome.breadcrumbs`) instead of
failing the measurement.
- Each page's `map<Page>Concept(reference)` applies only the recorded deviations, each
commented with its D-number. `diffInventory` must then report nothing.
- Sizes, offsets and heights that follow from a removed element are derived from the
concept's own measurements, never copied from production.
- `mutate(page, {css, remove})` appends a stylesheet and detaches elements, and returns
an undo. Each comparison has a mutation check proving it fails. For the shell, a
`.sidebar nav a { min-height: 30px }` rule must fail at `chrome.navs.0.rows.0.minHeight`
and `.height`, and removing `.breadcrumbs` must fail at `chrome.breadcrumbs`.
- Paired concept/production captures are attached as test artifacts. There are no PNG
baselines.
`tests/browser/shell-concept.spec.mjs` compares the shell: the sidebar, topbar, page
heading and footer, at 1600x1100 and 390x844, in dark and light, under normal and
reduced motion, on Images, Prompt library and Tags. The heading is compared in full on
Tags; on Images and Library only its frame and h1 are compared, because their copy and
actions belong to their own PRs. The spec also compares the hover state of the first
non-current nav row, the Theme select and the ComfyUI link, and the keyboard rings of
the first nav link and heading button.
Recorded shell deviations:
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
palette, then the OS.
- **D5**: no Activity button or Activity row until an activity center exists.
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
System plus the production Overview hub (its own outline home glyph, not the concept's
`activity` pulse, and no count).
The connection reads "Your library, on your server" with a neutral muted dot.
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
library.", and the title has no " concept" suffix.
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
slice.
- **Brand name**: not a deviation. The brand link's accessible name matches the
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
either. The inventory compares `brand.name` and `brand.mark.hidden`.
```sh
npm run --prefix tools/vendor check
npm test --prefix tools/vendor
npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
`tests/browser/images-concept.spec.mjs` compares the Images page (#249 C7): heading,
toolbar, filter row, caption, grid, the first six cards, the continuation and the panel
box, in Grid, List, Masonry and the Large and Compact sizes, at 1600x1100 and 390x844
in both themes and motion settings; grid, cards and panel also at 1200x900 (2 columns,
275px panel) and 1800x1100 (4 columns, 310px panel). The concept is compared once its
own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, production
40); the Load more row is compared before the press in both documents. Behaviors: hover
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 (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
unit per card above in the column (D3). `one footer height for the collection (D3)`
seeds two seedless files (`seedless_images` on `/__test__/concept/reset`) and asserts
that every row reserves the tallest footer.
### Images page controls and recorded deviations (#249 PR C)
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
- **Heading**: "Images" / "Your generations, their prompts, and the next idea.",
`Import` (opens "Import & inspect") and the shell's `New prompt`.
- **Toolbar** (`toolbar.mjs`, shared with the Library in PR E): `#search` "Search
prompts, tags, filenames…" (debounced 250 ms; filename, linked positive text and
Library title, and tag names), `#category-filter` "All categories" plus the
Library's categories, `#sort` (Recently added, Name A–Z, Highest rated, then Oldest
first and Name Z–A), and `Filters` (opens "Refine this view").
- **Filter row**: Grid / List / Masonry (`.segmented`, group "Image layout"),
"Thumbnail size" Comfortable / Large / Compact, the scope chip ("All output folders"
or "<source> · <folder>"), and "Group by positive prompt →" (goes to the Library).
- **Caption**: "N of M images · K positive prompts" (`#result-count`) and, under
Recently added only, the newest result's day.
- **Grid** (`#image-grid`): concept media cards. The image opens the viewer
(click, Enter, Space); Info selects the card into the side panel; the star toggles
the favorite; arrows, Home and End move between cards. Bounded rendering keeps the
DOM small, and returning to Images restores filters, layout, size, selection and
scroll.
- **Continuation** (`#gallery-continuation`): scrolling near it loads the next page;
`Load more images` and `Retry` do the same by hand, with a toast per batch.
- **Viewer** (`dialog#viewer-dialog`): "<title> · <filename>", the concept toolbar
(a "Media controls" group), the shared file inspector beside the stage, and Escape
returns focus to the opening image.
How the earlier workspace Images filters moved (old → new). The legacy `/gallery`,
`/admin` and `/metadata` pages are unchanged.
| Before PR C | Now |
| --- | --- |
| "Search filenames" (filename or folder) | toolbar search, which also matches linked prompt text, title and tags (D12) |
| Source | "Refine this view" → Source (D14); shown in the scope chip |
| Folder | "Refine this view" → Folder (D14); shown in the scope chip |
| Media (All media / Images / Videos) | "Refine this view" → Media (D14) |
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A, plus Highest rated (D13, D14) |
| (none) | toolbar category and "Refine this view" → Tags, through the linked positive (D13, D14) |
| Refresh | Import → "Scan an output folder" → Rescan (D15) |
| Grid / List / Masonry, Comfortable / Large / Compact | the same choices in the concept filter row |
| Card image → side panel; preview icon → viewer | image → viewer; Info → side panel (D19) |
| "All images" heading and count | the concept caption and count line (D17, D35) |
Recorded deviations on this page (spec §8):
- **D3**: bounded rendering stays. Every grid row reserves the tallest measured card
footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per
card above in the column), and a List row's info takes its natural height, centred
beside the 160×95 art.
- **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**: 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`.
- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
- **D14**: Source, Folder, Media and Tags live in "Refine this view" (no "Prompt
evidence" field, no scope note); the primary action reads "Apply filters"; extra
sorts come after the concept's three; long folder names wrap inside the scope chip.
Folder lists only the chosen Source's folders (each workspace root lists its own),
and an unchanged Apply or Clear reloads nothing.
- **D15**: Import offers "Inspect a local image" (`#/inspect`) and "Scan an output
folder" (Rescan, `/gallery/rescan`); no "Import portable recipes". The concept has
no rescan copy: toasts say "Rescanning output folders…", then "Rescan finished. N
files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
finish. Try again."; while it runs, a reopened Import shows an aria-disabled
"Rescanning…".
- **D16 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
outside the current year), hidden under every other sort.
- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
- **D32**: the layout control may wrap, so the filter and continuation rows never
overflow at 390px and 200% zoom.
- **D33**: after Load more or Retry, focus moves to the first new image without
scrolling, and only while focus is still on the pressed control or `body`.
- **D34**: the viewer keeps its toolbar under the stage in `.viewer-left`, a flex
column above 700px, `max-height: min(94dvh, 94%)`, the "Download original" link,
the appended position and reason lines, full-stage video and failure overlay,
40×40 coarse targets, production's control names and "Media controls" group, and
`overscroll-behavior: contain`.
- **D35**: counts pluralize ("1 positive prompt", "1 image", "1 more image loaded").
- **D36**: the category list is the Library's, sorted alphabetically (case-insensitive)
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.
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
Info (the side panel changes); search a word from a prompt (matching images remain);
open Filters (Source, Folder and Media are there); Import → Rescan picks up a newly
saved file.
```sh
npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
workspace-images.spec.mjs full-inspector.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
`mode=workspace`) and the legacy pages do not change, and existing workspace pages
ignore the new keys.
Workspace image items (`/prompt_manager/images/output?mode=workspace`, and Library
image galleries) gain three card facts. Library list previews carry none of them
and skip the parse.
- `linked: {status, positive_id, title, title_source, category, rating}`. One
`get_image_link_summaries` SELECT per 200 paths, matched exactly as the
inspection route matches (exact or normalized `file_path`, no basename
fallback). `status` is `linked`, `ambiguous` (several owners), `none`, or
`unavailable` when the database read failed (logged, no error text). Treat
anything but `linked` as the filename fallback. In pair scope (the image has
exactly one distinct pair), `positive_id` is the pair's positive, and the title
comes from that positive's Library title, then its text's first line, never
from the pair's own title, which names the negative variant (#249 D10/D20). In
positive scope it comes from the Library title, then the first line. A first line longer than 80 code points is cut to 80 plus `…`, like
`library.mjs` `title()`. `title_source` is `stored`, `text` or null. `category`
and `rating` come from the linked positive (D13), not from the pair. The title
gate is shared with the inspector (`_verified_title`): a `stored` title always
shows. A `text` title shows only when the file's own positive, parsed from its
records by the shared parser, gives the same display title. Otherwise the title
is null on the card and in the inspector header. Videos and unreadable files
have no parsed positive, so their `text` titles are withheld.
- `seed`: a decimal string, or null. Always a string, because `JSON.parse` rounds
numbers past 2**53 and ComfyUI's randomized seeds are often that large. It is
`extract_generation_params` from `utils/generation_params.py` (the inspector's
legacy parser, #251), run on the `prompt`/`workflow` records read with the
dimensions. A PNG is read by the inspector's own reader
(`utils.prompt_inspector.read_inspection_file`): text after IDAT counts, the
shared byte budgets apply, and a file with conflicting duplicate `prompt` or
`workflow` chunks gets no card seed, as the inspector refuses it (the card
keeps its dimensions). Other formats use Pillow's header `info`, as the
inspector's header read does. The records go through the inspection
projection first (`utils/inspection_response.project_graph`), which carries
integers past 2**53 as their exact digits, so the card and the inspector read
the same digits. The parse runs after the file handle closes. A record over
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
non-integral, boolean or non-finite seed gives null. A card never shows a seed
that differs from the inspector's
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
the listing in Node and compares `String(seed)` with the inspector's seed,
including one past 2**53). The card shows no seed for A1111 `parameters` files
or for a BasicGuider custom sampler, where the inspector does.
- Header facts are cached. Dimensions, `seed` and the parsed positive's display
title are read once per `(path, mtime)` of the current gallery scan and kept
beside the scan's per-file values. A monitor invalidation or TTL rescan keeps
the facts of unchanged files and drops the rest, and a roots change drops them
all. A header that could not be read is not stored. Links and favorites are
never cached (`test_warm_page_reuses_card_facts_per_scan`: a warm page reads
and parses no header). Measured cold cost is about 3.4 ms per 50 KB record set
and 25 ms per 400 KB workflow.
- `favorite: bool`. One favorites SELECT per root per page. A read failure logs a
warning and shows false.
Filters (`mode=workspace`; anything invalid is 400 "Invalid gallery filters"):
- `q` (at most 500 characters). An entry passes when the case-folded relative path
contains it, or when its linked positive's text, Library title or a tag name
contains it (SQLite `LIKE` with escaping, ASCII case folding only). A pair's own
title names its negative variant, so it is not searched (#249 D12): a variant
name alone matches nothing. Search does not apply the card title gate, and does
not search pair tags.
- `category` (exact and case-sensitive, at most 200 characters, stripped; blank
means none) and `tags` (a comma list, blanks dropped, order-free, AND, at most 30
tags of at most 200 characters) admit linked images only, through the linked
positive.
- Ambiguous files never match a linked filter, and they rank as unlinked.
- `sort` is `newest | oldest | name | name-desc | rating`. `rating` orders by
rating 5 to 1, then linked but unrated, then unlinked and ambiguous; ties break
newest first. A signed cursor covers every filter and the sort, so reusing it
with a change is 400.
- One link read (`get_image_link_matches`, streamed and bounded to the listed
paths) serves the filters, the rating sort and the count.
`positive_count` is the number of distinct linked positives over the full matching
set, not the page:
- Present on every first page, and on every page of a filtered (`q`, `category`,
`tags`) or `sort=rating` listing.
- Absent on a plain listing's cursor pages, which read no links. The frontend
keeps the first page's value.
- Null when a plain first page's link read failed. The listing still returns 200,
and consumers then hide "· K positive prompts". A `q` search alone also
survives the failure: it matches filenames only, as before linked search, and
every page's count is null (logged with the traceback). A failure under
`category`, `tags` or the rating sort stays a safe 500.
Library parents (list, detail and command receipts) gain `run_count` (an int; the
D23 migration estimate for rows from before 3.2.4) and `last_used_at`, a UTC ISO
string. A naive value reads as UTC, and an unreadable or out-of-range value is
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.
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
`{root_index, path}`. Both are idempotent and answer
`{success, favorite, root_index, path}`. The boundary returns 400 for an unknown
root or a malformed body, and 403 for traversal, an absolute or drive path, NUL, a
non-media name, or a key over the stored bound. PUT needs a listed file (404
otherwise). DELETE also clears a path that is no longer listed. Send the DELETE
body as `application/json` (`api.del(url, {body})` in the shared API client).
The Images card star (#249 C5) toggles optimistically, allows one request per file
at a time, and on any failure restores the previous state and announces it in the
shared toast. The browser fixtures clear `image_favorites` on reset, and the
concept fixture stars the concept's favorites (each other prompt's first image).
- The table is `image_favorites(root_key, relative_path, created_at)`. `root_key`
is the root's real path (`os.path.realpath`), case-normalized with `/`
separators. It is stored server-side only and never returned. Folder labels are
not unique across roots, so they are not used as keys.
- `relative_path` is `normcase(path)` with `/`. On Windows a case-only rename
keeps the star. On POSIX the key is the exact spelling, so `A.png` and `a.png`
stay separate (tests swap in `ntpath` through `py.api.images._path_flavor`).
Moving the install or changing a root's real path orphans its rows, which are
harmless and never shown.
- Schema validation (`image_favorites_schema_error`) runs at startup and on
restore. Old backups without the table restore, and backup plus restore keeps
WAL-resident favorites and closes every handle (`tests/test_database_backup.py`).
Budgets: `test_filter_work_is_bounded` always runs. It asserts that a plain first
page makes 1 link SELECT and a plain cursor page none, that every filter shape
makes exactly 1, the covering-index plan, and that the per-file values are built
once per scan and only for new files after a monitor invalidation. The opt-in
`test_filter_budget` (`PROMPTMANAGER_PERF_BUDGET=1`, the `perf budget` job) times
the D12 p95 < 150 ms budget; see "Performance budgets".
```sh
python -m unittest tests.test_api_workspace_images tests.test_card_seed \
tests.test_image_favorites tests.test_library tests.test_api_library \
tests.test_database_backup -q
```
Installing these fields needs a maintainer ComfyUI restart (Python, routes and
schema). Automated tests use disposable databases and output folders.
## Prompt sending and cross-pack compatibility
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
@@ -534,9 +996,9 @@ 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. Broader Images card
activation/Info/tag/favorite/select parity and shared Viewer stage/toolbar polish
and shared role-block spacing/padding/glyph polish remain outside #209. The
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
a whole-application or full-gallery/Viewer pixel-parity claim.
+139
View File
@@ -0,0 +1,139 @@
"""Persisted image favorites, keyed by gallery root + relative path.
The root key is the root's real, case-normalized path and the relative key is
case-normalized the same way (see ``py/api/images.py``); both stay
server-side. Folder names alone are not unique across roots, so a favorite
follows the directory itself when the configured roots are reordered.
Optional in old backups, required once a database is current (see
``library_schema.py``).
"""
import re
import sqlite3
from typing import Iterable
TABLE = "image_favorites"
MAX_KEY_LENGTH = 4096
_LOOKUP_CHUNK = 500
_DDL = (
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
"created_at TEXT NOT NULL, PRIMARY KEY(root_key, relative_path))"
)
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
_COLUMNS = (
("root_key", "TEXT", 1, 1),
("relative_path", "TEXT", 1, 2),
("created_at", "TEXT", 1, 0),
)
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql).lower()
def image_favorites_schema_error(
conn: sqlite3.Connection, *, allow_missing: bool = True
) -> str | None:
"""Reject unsupported favorites schemas and invalid persisted values.
Reads only. A missing table is acceptable for backups that predate it
unless ``allow_missing`` is false.
"""
objects = conn.execute(
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
"OR (type IN ('index','trigger') AND tbl_name=?)",
(TABLE, TABLE),
).fetchall()
if not objects:
return None if allow_missing else "Missing image favorites schema"
tables = [sql for kind, _name, sql in objects if kind == "table"]
others = {(kind, name, sql) for kind, name, sql in objects if kind != "table"}
if len(tables) != 1 or others != {("index", _AUTOINDEX, None)}:
return "Image favorites name is reserved by an unsupported schema object"
if _compact(tables[0] or "") != _compact(_DDL):
return "Image favorites has an invalid table definition"
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_info({TABLE})")
)
if columns != _COLUMNS:
return "Image favorites has invalid columns"
# Bytes bound characters from above (UTF-8 is at most 4 bytes each).
limit = MAX_KEY_LENGTH * 4
if conn.execute(
f"SELECT 1 FROM {TABLE} WHERE typeof(root_key) != 'text' "
"OR typeof(relative_path) != 'text' OR typeof(created_at) != 'text' "
f"OR length(CAST(root_key AS BLOB)) > {limit} "
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
"OR length(CAST(created_at AS BLOB)) > 64 LIMIT 1"
).fetchone():
return "Image favorites contains invalid values"
for root, path in conn.execute(
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) FROM {TABLE} "
f"WHERE length(CAST(root_key AS BLOB)) > {MAX_KEY_LENGTH} "
f"OR length(CAST(relative_path AS BLOB)) > {MAX_KEY_LENGTH}"
):
for raw in (root, path):
if len(bytes(raw).decode("utf-8", "replace")) > MAX_KEY_LENGTH:
return "Image favorites contains invalid values"
return None
def migrate_image_favorites(conn: sqlite3.Connection) -> None:
"""Install the table atomically; validate before and after creating it."""
conn.execute("SAVEPOINT image_favorites_schema")
try:
error = image_favorites_schema_error(conn)
if error:
raise sqlite3.DatabaseError(error)
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
error = image_favorites_schema_error(conn, allow_missing=False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE image_favorites_schema")
except BaseException:
conn.execute("ROLLBACK TO image_favorites_schema")
conn.execute("RELEASE image_favorites_schema")
raise
class ImageFavoriteOperations:
"""PromptDatabase mixin: set, clear and look up image favorites."""
def set_image_favorite(self, root_key: str, relative_path: str) -> bool:
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
from .models import utc_now_iso # models imports this module's schema
with self.model.get_connection() as conn:
conn.execute(
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
(root_key, relative_path, utc_now_iso()),
)
return True
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
"""Remove one favorite; True when a row was removed."""
with self.model.get_connection() as conn:
cursor = conn.execute(
"DELETE FROM image_favorites WHERE root_key=? AND relative_path=?",
(root_key, relative_path),
)
return cursor.rowcount > 0
def get_image_favorites(
self, root_key: str, relative_paths: Iterable[str]
) -> set[str]:
"""The subset of *relative_paths* favorited under *root_key*."""
wanted = list(dict.fromkeys(relative_paths))
found: set[str] = set()
conn = self.model.get_connection()
for start in range(0, len(wanted), _LOOKUP_CHUNK):
chunk = wanted[start : start + _LOOKUP_CHUNK]
marks = ",".join("?" * len(chunk))
rows = conn.execute( # only "?" placeholders are interpolated
"SELECT relative_path FROM image_favorites WHERE root_key=? "
f"AND relative_path IN ({marks})", # nosec B608
(root_key, *chunk),
)
found.update(path for (path,) in rows)
return found
+88 -7
View File
@@ -1,5 +1,6 @@
"""Read-only positive-parent views over existing prompt and pair tables."""
import datetime
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
@@ -13,10 +14,79 @@ _ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id
_FROM = "prompts p LEFT JOIN prompt_library_state ls ON ls.prompt_id=p.id"
# The characters JavaScript's String.prototype.trim() removes, so a title the
# display treats as blank also falls back to the prompt text for name sorting.
_BLANK = (
"char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,"
"8200,8201,8202,8232,8233,8239,8287,12288,65279)"
)
_TRIM_CODES = (9, 10, 11, 12, 13, 32, 160, 5760, *range(8192, 8203))
_TRIM_CODES += (8232, 8233, 8239, 8287, 12288, 65279)
_BLANK = f"char({','.join(map(str, _TRIM_CODES))})"
_JS_TRIM = "".join(map(chr, _TRIM_CODES))
# The Library's display title: a stored title, else the prompt's first line.
DISPLAY_TITLE_LIMIT = 80
def _utf16_units(text: str) -> int:
"""JavaScript ``length``: characters beyond the BMP count as two units."""
return len(text) + sum(1 for ch in text if ord(ch) > 0xFFFF)
def _utf16_prefix(text: str, units: int) -> str:
"""The longest prefix within ``units`` UTF-16 units, never splitting a pair."""
used = 0
for index, ch in enumerate(text):
used += 2 if ord(ch) > 0xFFFF else 1
if used > units:
return text[:index]
return text
def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]:
"""Return ``(title, source)``: a stored title, else the text's first line.
``source`` is ``"stored"`` for the first non-blank stored title (blank as
in JavaScript's trim, like the Library view), ``"text"`` for the first line,
and None when both are empty. The first line is trimmed and, past
DISPLAY_TITLE_LIMIT UTF-16 units, cut with an ellipsis like the Library's
``libraryTitle()`` (never inside a surrogate pair).
"""
for title in stored_titles:
if isinstance(title, str) and title.strip(_JS_TRIM):
return title, "stored"
if not isinstance(text, str):
return None, None
first = text.split("\n")[0].strip(_JS_TRIM)
if _utf16_units(first) > DISPLAY_TITLE_LIMIT:
first = _utf16_prefix(first, DISPLAY_TITLE_LIMIT) + "\u2026"
return (first, "text") if first else (None, None)
def display_title(stored_titles: tuple, text: object) -> str | None:
"""The Library display title of ``titled``, without its source."""
return titled(stored_titles, text)[0]
def utc_iso(value: object) -> str | None:
"""A stored timestamp as UTC ISO text, or None when absent or unreadable.
Usage writes are already UTC ISO; a naive value (SQLite's CURRENT_TIMESTAMP
form) is read as UTC, and an offset is converted to UTC. One bad row must
not fail a whole Library read, so a value past the datetime range after
conversion (OverflowError) is unreadable too. Python 3.11+ fromisoformat
accepts more ISO forms than 3.10 (e.g. a 2-digit fraction); every form the
app writes (utc_now_iso, the 3.2.4 backfill, CURRENT_TIMESTAMP, Z) parses
the same on both, so only hand-edited values can differ by version.
"""
if not isinstance(value, str):
return None
text = value.strip()
if text.endswith(("Z", "z")):
text = text[:-1] + "+00:00"
try:
stamp = datetime.datetime.fromisoformat(text)
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
return stamp.astimezone(datetime.timezone.utc).isoformat()
except (ValueError, OverflowError):
return None
_NAME = (
f"LOWER(CASE WHEN TRIM(COALESCE(ls.title,''),{_BLANK})!='' "
"THEN ls.title ELSE p.text END)"
@@ -145,6 +215,7 @@ class LibraryOperations:
) -> list[dict]:
rows = conn.execute(
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
"p.run_count,p.last_used_at,"
+ (f"{sort_expression} AS _sort," if sort_expression else "")
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
"AND v.archived=0) AS variant_count,"
@@ -165,6 +236,9 @@ class LibraryOperations:
):
by_id[row["prompt_id"]]["tags"].append(row["name"])
for row in result:
# Usage is shown, never compared: the token hashes owner fields only.
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
row["last_used_at"] = utc_iso(row["last_used_at"])
row["edit_token"] = library_edit_token(epoch, row)
return result
@@ -196,9 +270,15 @@ class LibraryOperations:
ceiling = after["ceiling"]
where += " AND p.id<=?"
params.append(ceiling)
total = conn.execute(
"SELECT COUNT(*) FROM " + _FROM + " WHERE " + where, params
).fetchone()[0]
# One SELECT counts the filtered parents and their active variants.
total, variant_total = conn.execute(
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
+ _FROM
+ " WHERE "
+ where,
params,
).fetchone()
expression, direction = _ORDERS[sort]
if after is not None:
operator = ">" if direction == "ASC" else "<"
@@ -230,6 +310,7 @@ class LibraryOperations:
"library_epoch": epoch,
"items": rows,
"total": total,
"variant_total": variant_total,
"has_more": has_more,
"next_key": next_key,
}
+10
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Optional, Set, Tuple
from .library_schema import library_schema_error, migrate_library_state
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
from .execution_history_schema import (
history_schema_error,
migrate_execution_history,
@@ -298,6 +299,7 @@ class PromptModel:
self._migrate_prompt_pairs(conn)
migrate_execution_history(conn)
migrate_library_state(conn)
migrate_image_favorites(conn)
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
conn.execute("SAVEPOINT prompt_pair_schema")
@@ -1016,6 +1018,9 @@ class PromptModel:
Args:
path: File to inspect
require_library_state: Also require the tables every current
database has (Library state and image favorites), as a
migrated restore stage must; old backups may lack them.
Returns:
(ok, reason): reason is "ok" when the file is acceptable, otherwise
@@ -1070,6 +1075,11 @@ class PromptModel:
)
if library_error:
return False, library_error
favorites_error = image_favorites_schema_error(
conn, allow_missing=not require_library_state
)
if favorites_error:
return False, favorites_error
return True, "ok"
except sqlite3.Error as e:
return False, f"Invalid SQLite database: {e}"
+215 -2
View File
@@ -6,11 +6,12 @@ import sqlite3
import json
import datetime
import os
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Set, Tuple
from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations
from .library import LibraryOperations
from .library import LibraryOperations, titled
from .image_favorites import ImageFavoriteOperations
from .library_commands import LibraryCommandOperations
from .vocabulary import VocabularyOperations
from .execution_history import ExecutionHistoryOperations
@@ -179,6 +180,122 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
"""
# One SELECT reads the link summaries of at most this many paths: three bound
# parameters per path stays under SQLite's historical 999-variable limit.
LINK_SUMMARY_BATCH = 200
# A pair link counts only when an image has exactly one distinct pair, as in
# get_image_prompt_info; its positive (text, Library title, category, rating)
# then describes the card. The pair's own title names its negative variant, so
# it is not read (D10).
_LINK_SUMMARY_SQL = """SELECT gi.image_path, gi.file_path, gi.prompt_id,
p.text, p.category, p.rating, ls.title,
pp.positive_prompt_id, pt.text, pt.category, pt.rating, pls.title
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
LEFT JOIN prompt_library_state ls ON ls.prompt_id = p.id
LEFT JOIN prompt_pairs pp ON pp.id = (
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
LEFT JOIN prompts pt ON pt.id = pp.positive_prompt_id
LEFT JOIN prompt_library_state pls ON pls.prompt_id = pp.positive_prompt_id
WHERE gi.image_path IN ({marks}) OR gi.file_path IN ({marks}){fallback}"""
# One image row describes a card only when no other row claims the same file
# (as in get_image_link_summaries, several rows are ambiguous). The pair scope
# and the linked positive follow _LINK_SUMMARY_SQL; category, rating, tags and
# the searchable text and titles come from that positive (spec D12/D13). Every
# writer since the 3.2.4 migration stores file_path, so it is the path key.
# The prompt-level conditions are uncorrelated IN sets SQLite builds once, and
# the CASE evaluates the per-row ambiguity probe only for admitted rows.
_LINK_FILTER_SQL = """SELECT gi.file_path, p.rating, {hit}, p.id
FROM generated_images gi
LEFT JOIN prompt_pairs pp ON pp.id = (
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
WHERE gi.file_path IS NOT NULL AND CASE WHEN {admit} THEN NOT EXISTS (
SELECT 1 FROM generated_images other
WHERE other.file_path = gi.file_path AND other.id <> gi.id) ELSE 0 END"""
# The positive's text, its Library title or one of its tag names contains
# :pattern (LIKE, so ASCII case is ignored as in Library search). A pair's own
# title names its negative variant and is never searched (#249 D12).
_LINK_FILTER_HIT = """p.id IN (
SELECT hp.id FROM prompts hp
LEFT JOIN prompt_library_state hls ON hls.prompt_id = hp.id
WHERE hp.text LIKE :pattern ESCAPE '\\' OR hls.title LIKE :pattern ESCAPE '\\'
OR hp.id IN (SELECT ht.prompt_id FROM prompt_tags ht JOIN tags hn
ON hn.id = ht.tag_id WHERE hn.name LIKE :pattern ESCAPE '\\'))"""
_LINK_FILTER_TAG = (
"p.id IN (SELECT ft.prompt_id FROM prompt_tags ft JOIN tags fn "
"ON fn.id = ft.tag_id WHERE fn.name = :tag{index})"
)
def link_filter_query(
q: str, category: Optional[str], tags: List[str], *, every_link: bool = False
) -> Tuple[str, Dict[str, Any]]:
"""The workspace filter SELECT and its named parameters (all bound).
Only code constants and enumerate indices are formatted into the SQL; see
PromptDatabase.get_image_link_matches for the semantics.
"""
params: Dict[str, Any] = {}
hit = "1"
if q:
hit = _LINK_FILTER_HIT
params["pattern"] = f"%{escape_like(q)}%"
admitted = []
if category is not None:
admitted.append("p.category = :category")
params["category"] = category
for index, tag in enumerate(tags):
admitted.append(_LINK_FILTER_TAG.format(index=index))
params[f"tag{index}"] = tag
if q and not admitted and not every_link:
admitted.append(hit)
sql = _LINK_FILTER_SQL.format(hit=hit, admit=" AND ".join(admitted) or "1")
return sql, params
_ASCII_FOLD = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
def _like_suffix(value: object, suffix: str) -> bool:
"""Python form of SQLite's ``value LIKE '%' || suffix``: ASCII-only case."""
return isinstance(value, str) and value.translate(_ASCII_FOLD).endswith(
suffix.translate(_ASCII_FOLD)
)
def _empty_link_summary(status: str) -> Dict[str, Any]:
return {
"status": status,
"positive_id": None,
"title": None,
"title_source": None,
"category": None,
"rating": None,
}
def _link_summary(row: tuple) -> Dict[str, Any]:
if row[7] is not None:
positive, text, category, rating = row[7], row[8], row[9], row[10]
# The linked positive's Library title (D10/D20).
titles = (row[11],)
else:
positive, text, category, rating = row[2], row[3], row[4], row[5]
titles = (row[6],)
title, source = titled(titles, text)
return {
"status": "linked",
"positive_id": positive,
"title": title,
"title_source": source,
"category": category,
"rating": rating,
}
class PromptDatabase(
PairOperations,
LibraryOperations,
@@ -188,6 +305,7 @@ class PromptDatabase(
ExecutionContextOperations,
ExecutionOutputOperations,
HistoryReadOperations,
ImageFavoriteOperations,
):
"""Database operations class for managing prompts."""
@@ -2160,6 +2278,101 @@ class PromptDatabase(
"parameters": row[8] if len(row) > 8 else None,
}
def get_image_link_summaries(
self, paths: List[str], allow_filename_fallback: bool = True
) -> Dict[str, Dict[str, Any]]:
"""Summarize each path's linked saved positive for an image card.
Matching equals get_image_prompt_info: exact image_path or normalized
file_path rows win; otherwise, with the fallback, rows whose image_path
ends with the basename. One match is linked, several are ambiguous.
One SELECT reads up to LINK_SUMMARY_BATCH paths.
"""
unique = list(dict.fromkeys(paths))
summaries: Dict[str, Dict[str, Any]] = {}
for start in range(0, len(unique), LINK_SUMMARY_BATCH):
batch = unique[start : start + LINK_SUMMARY_BATCH]
summaries.update(self._link_summary_batch(batch, allow_filename_fallback))
return summaries
def _link_summary_batch(
self, paths: List[str], allow_filename_fallback: bool
) -> Dict[str, Dict[str, Any]]:
normalized = {path: normalize_image_path(path) for path in paths}
basenames = {path: os.path.basename(path) for path in paths}
marks = ",".join("?" for _ in paths)
params: List[Any] = [*paths, *normalized.values()]
fallback = ""
if allow_filename_fallback:
fallback = "".join(" OR gi.image_path LIKE ? ESCAPE '\\'" for _ in paths)
params.extend(f"%{escape_like(basenames[path])}" for path in paths)
sql = _LINK_SUMMARY_SQL.format(marks=marks, fallback=fallback)
with self.model.get_connection() as conn:
rows = conn.execute(sql, params).fetchall()
summaries = {}
for path in paths:
exact = [
row for row in rows if row[0] == path or row[1] == normalized[path]
]
matches = exact or (
[row for row in rows if _like_suffix(row[0], basenames[path])]
if allow_filename_fallback
else []
)
if len(matches) == 1:
summaries[path] = _link_summary(matches[0])
else:
summaries[path] = _empty_link_summary(
"ambiguous" if matches else "none"
)
return summaries
def get_image_link_matches(
self,
q: str,
category: Optional[str],
tags: List[str],
*,
every_link: bool = False,
wanted: Optional[Set[str]] = None,
) -> Dict[str, Tuple[bool, Optional[int], int]]:
"""Map normalized paths of unambiguously linked images to
(hit, rating, positive id).
Rows are limited to images whose linked positive has *category* and
every tag in *tags* (exact names). ``hit`` says whether *q* occurs in
the positive's text, Library title or a tag name; a pair's own title
names its negative variant and is not searched.
Without category or tags only hits are returned, unless *every_link*
asks for every linked image (the rating sort and the positive count).
The positive id is the card's linked positive. One SELECT whose rows
are streamed; with *wanted* (the listing's normalized paths) only those
paths are kept, so memory is bounded by the listing, not the database.
"""
sql, params = link_filter_query(q, category, tags, every_link=every_link)
matches: Dict[str, Tuple[bool, Optional[int], int]] = {}
with self.model.get_connection() as conn:
for path, rating, matched, positive in conn.execute(sql, params):
if wanted is None or path in wanted:
matches[path] = (bool(matched), rating, positive)
return matches
def get_image_paths_matching(
self, q: str, category: Optional[str], tags: List[str]
) -> Set[str]:
"""Normalized paths whose linked positive matches *q*, *category* and *tags*.
See get_image_link_matches; an empty *q* matches every linked image
the category and tags admit.
"""
return {
path
for path, (hit, _rating, _positive) in self.get_image_link_matches(
q, category, tags
).items()
if hit or not q
}
def get_prompt_id_for_image(self, image_path: str) -> Optional[int]:
"""Return the prompt_id linked to an image, or None."""
with self.model.get_connection() as conn:
+24 -1
View File
@@ -283,6 +283,16 @@ class PromptManagerAPI(
self._html_cache = {} # Cached HTML file contents keyed by path
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
self._gallery_cache_ttl = 30 # Cache TTL in seconds
# Workspace listing values and sort orders derived from a cached scan
# (py/api/images.py _workspace_listed/_workspace_orders).
self._workspace_listed_cache = {}
self._workspace_order_cache = {}
# Card facts (dimensions, seed, positive title) per root and
# (path, mtime), kept and pruned with the per-file values
# (py/api/images.py _card_facts).
self._workspace_card_cache = {}
# Favorite keys of each root's cached scan (images._favorite_listing).
self._favorite_listing_cache = {}
# Single-flight locks for long jobs, keyed by job family; see
# _acquire_long_job(). Created lazily so no lock binds to an event
# loop before the server's loop exists.
@@ -348,9 +358,22 @@ class PromptManagerAPI(
def invalidate_gallery_cache(self):
"""Invalidate the cached gallery file listing.
Called by the image monitor when new files are detected.
Called by the image monitor when new files are detected. The workspace
keeps its per-file values and card facts, so the next request rebuilds
only new or changed files (see ImageRoutesMixin._workspace_listed and
_card_facts); its sort orders
are dropped, as the rescan replaces them anyway, and so is the
favorites lookup, which holds the old scan list.
"""
self._gallery_cache = {}
self._workspace_order_cache = {}
self._favorite_listing_cache = {}
def reset_gallery_roots_cache(self):
"""Drop every cached listing and derived workspace value (roots changed)."""
self.invalidate_gallery_cache()
self._workspace_listed_cache = {}
self._workspace_card_cache = {}
def add_routes(self, routes):
"""Register all API routes with the ComfyUI server.
+53 -17
View File
@@ -27,10 +27,22 @@ except ImportError:
try:
from ...database.operations import PromptDatabase
from ...database.pair_ingestion import ingest_resolved_pair
from ...utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_js_json,
)
from ...utils.prompt_pair_resolver import resolve_prompt_pair
except ImportError:
from database.operations import PromptDatabase
from database.pair_ingestion import ingest_resolved_pair
from utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_js_json,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
@@ -885,7 +897,7 @@ class AdminRoutesMixin:
return False
GalleryConfig.MONITORING_DIRECTORIES = roots
self._cached_output_dir = None
self._gallery_cache = {}
self.reset_gallery_roots_cache()
return True
def _persist_settings(self):
@@ -1511,6 +1523,21 @@ class AdminRoutesMixin:
counts["pair_links_added"] += outcome.get("links_added", 0)
return counts
@staticmethod
def _graph_prompt_text(metadata):
"""The positive prompt the browser parser traces from the graphs, or None."""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_js_json(value) if isinstance(value, str) else value
try:
positive = extract_generation_params(records)["positivePrompt"]
except GenerationParamsError:
return None
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
return None
return positive.strip() or None
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
if not metadata:
@@ -1531,23 +1558,32 @@ class AdminRoutesMixin:
self.db, str(media_file), canonical, resolution
)
return result if detailed else result["status"]
if resolution["state"] == "ambiguous":
return "ambiguous"
if resolution["state"] == "unresolved":
if canonical.get("prompt") or not isinstance(
canonical.get("parameters"), str
):
if canonical.get("prompt") and resolution["state"] in (
"ambiguous",
"unresolved",
):
# The resolver could not decide: link the prompt the inspector and the
# legacy pages show for this file (the browser parser, in Python).
# Graphs without a recognized sampler keep the legacy extractor below.
prompt_text = self._graph_prompt_text(metadata)
if not prompt_text:
return resolution["state"]
parsed = self._parse_comfyui_prompt(metadata)
if not (parsed.get("prompt") or parsed.get("parameters")):
return None
prompt_text = self._extract_readable_prompt(parsed)
if prompt_text is not None and not isinstance(prompt_text, str):
prompt_text = str(prompt_text)
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
elif resolution["state"] == "ambiguous":
return "ambiguous"
elif resolution["state"] == "unresolved" and not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
else:
parsed = self._parse_comfyui_prompt(metadata)
if not (parsed.get("prompt") or parsed.get("parameters")):
return None
prompt_text = self._extract_readable_prompt(parsed)
if prompt_text is not None and not isinstance(prompt_text, str):
prompt_text = str(prompt_text)
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
prompt_hash = generate_prompt_hash(prompt_text)
existing = self.db.get_prompt_by_hash(prompt_hash)
+728 -123
View File
File diff suppressed because it is too large Load Diff
+40 -14
View File
@@ -9,6 +9,11 @@ try:
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
unreadable_evidence,
)
from ...utils.prompt_pair_resolver import (
INSPECTION_PROFILE,
PREVIEW_INSPECTION_PROFILE,
)
from ...utils.video_metadata import is_video_path
except ImportError:
@@ -16,6 +21,11 @@ except ImportError:
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
unreadable_evidence,
)
from utils.prompt_pair_resolver import (
INSPECTION_PROFILE,
PREVIEW_INSPECTION_PROFILE,
)
from utils.video_metadata import is_video_path
@@ -60,6 +70,28 @@ def _current_file_facts(path: Path) -> os.stat_result:
return observed
def _in_host_temp(path: Path) -> bool:
"""Whether a file lies in ComfyUI's temp folder, where previews are written.
An unknown temp folder reads as no: the file is then inspected as saved
output.
"""
try:
import folder_paths
temp = folder_paths.get_temp_directory()
except (ImportError, AttributeError, OSError):
return False
if type(temp) is not str or not temp:
return False
try:
folder = os.path.normcase(os.path.realpath(temp))
target = os.path.normcase(os.path.realpath(path))
return os.path.commonpath([folder, target]) == folder
except (OSError, ValueError):
return False
def inspect_current_file(
path: Path, *, source_label: str, saved: dict | None, library: dict
) -> dict:
@@ -73,24 +105,18 @@ def inspect_current_file(
if any(getattr(before, key) != getattr(after, key) for key in fields):
raise CurrentFileChanged
if inspection is None:
evidence = {
"prompts": {
role: {"status": "unresolved", "text": None, "source": None}
for role in ("positive", "negative")
},
"resolution": {
"state": "unresolved",
"sampler_id": None,
"reason": "Media metadata could not be read",
"reason_code": None,
"node": None,
},
}
evidence = unreadable_evidence()
else:
role_saved = (
None if saved and saved.get("association_status") == "ambiguous" else saved
)
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
# A preview may be the output of a stage that reaches only previews.
profile = (
PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE
)
evidence = inspect_prompt_evidence(
inspection.metadata, role_saved, profile=profile
)
return {
"file": {
"observation": "current-file",
+8 -3
View File
@@ -233,7 +233,9 @@ class LibraryRoutesMixin:
self.add_library_negative
)
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
def _library_media(
self, records: list[dict], roots: list[Path], *, linked: bool = True
) -> list[dict]:
page = []
for record in records:
try:
@@ -251,7 +253,7 @@ class LibraryRoutesMixin:
break
except (OSError, ValueError, RuntimeError):
continue
return self._format_workspace_page(page)
return self._format_workspace_page(page, linked=linked)
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
@@ -296,7 +298,10 @@ class LibraryRoutesMixin:
def previews() -> None:
for parent in result["items"]:
parent["previews"] = self._library_media(records[parent["id"]], roots)
# Previews belong to their parent; a per-parent link read is N+1.
parent["previews"] = self._library_media(
records[parent["id"]], roots, linked=False
)
await self._run_in_executor(previews)
return web.json_response({"success": True, **result})
+43 -5
View File
@@ -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
+346
View File
@@ -0,0 +1,346 @@
// Shared concept-vs-production inventory for the mock-true rebuild (#249, spec §7). One in-page measurer runs the
// same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production, which now renders
// the concept's classes. The concept is never rewritten: each page's map<Page>Concept(reference) applies only the
// recorded spec-§8 deviations, each commented with its D-number, and diffInventory must then report nothing.
import {expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
export {diffInventory} from './library-editor-canonical-comparison.mjs';
export const CONCEPT_URL='/__test__/canonical/workspace';
export const PRODUCTION_URL='/prompt_manager/workspace';
export const SIZES=Object.freeze([[1600,1100],[390,844]]);
export const THEMES=Object.freeze(['dark','light']);
export const MOTIONS=Object.freeze(['no-preference','reduce']);
/** D35: production uses the singular for exactly one; the concept's plurals are hard-coded (app.js 447, 489, 517-521). */
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');
/** 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});
await expect(response).toBeOK();
return response.json();
}
async function selectTheme(page,theme) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
}
/** The concept at `#<route>` with its own Theme select. */
export async function openConcept(page,route,theme,motion='no-preference') {
await page.emulateMedia({reducedMotion:motion});
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
await selectTheme(page,theme);
await expect(page.locator('#main #page-title')).toBeVisible();
}
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
export async function openProduction(page,route,theme,motion='no-preference') {
await page.emulateMedia({reducedMotion:motion});
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
}
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
export async function focusRingOf(locator) {
return locator.evaluate(node=>{const style=getComputedStyle(node);return {focused:document.activeElement===node,
visible:node.matches(':focus-visible'),outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,
outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
}
/** Computed colors of an element while the pointer rests on it; the pointer is parked again afterwards. */
export async function hoverStateOf(page,locator) {
await locator.hover();
const state=await locator.evaluate(node=>{const style=getComputedStyle(node);return {hovered:node.matches(':hover'),
color:style.color,backgroundColor:style.backgroundColor,borderTopColor:style.borderTopColor,
textDecorationLine:style.textDecorationLine,boxShadow:style.boxShadow};});
await page.mouse.move(0,0);
return state;
}
/**
* Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as
* the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector
* is detached. Returns an async undo that restores the stylesheet and the removed elements in place.
*/
export async function mutate(page,{css=null,remove=[]}={}) {
const token=await page.evaluate(({css,remove})=>{
const token=`mutation-${Math.random().toString(36).slice(2)}`;
const undo=[];
if(css) {
const style=document.createElement('style');style.dataset.mutation=token;style.textContent=css;
document.head.append(style);undo.push(()=>style.remove());
}
for(const selector of remove) {
const nodes=[...document.querySelectorAll(selector)];
if(!nodes.length)throw new Error(`mutate: nothing matches ${selector}`);
for(const node of nodes) {
const parent=node.parentNode,next=node.nextSibling;
node.remove();undo.push(()=>parent.insertBefore(node,next));
}
}
(window.__conceptMutations??=new Map()).set(token,undo);
return token;
},{css,remove});
return ()=>page.evaluate(token=>{
const undo=window.__conceptMutations?.get(token);
if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded');
undo.reverse().forEach(step=>step());window.__conceptMutations.delete(token);
},token);
}
/**
* 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 and history stay null until PRs E and F.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
}
/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */
export function stripExact(value) {
if(Array.isArray(value))return value.map(stripExact);
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)]));
return value;
}
/** Runs in the page; self-contained. `scope` selects the page's main element. */
export function measureInventory(scope) {
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','borderRightStyle','borderRightColor',
'borderBottomWidth','borderBottomStyle','borderBottomColor','borderTopLeftRadius','backgroundColor');
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap','position');
// Text of a node without its descendants matching `skip` (counts, nested controls), whitespace collapsed.
const textOf=(node,skip)=>{const copy=node.cloneNode(true);if(skip)copy.querySelectorAll(skip).forEach(item=>item.remove());
return copy.textContent.replace(/\s+/g,' ').trim();};
const relative=(node,origin)=>{const a=rect(node),b=rect(origin);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
const main=document.querySelector(scope);
const sidebar=document.querySelector('aside.sidebar');
const workspace=document.querySelector('.workspace');
function iconOf(node) {
const svg=node.querySelector(':scope > svg.icon');
if(!svg)return null;
const box=rect(svg);
return {width:px(box.width),height:px(box.height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color','verticalAlign','flexShrink')};
}
function navRow(link,index,links) {
const box=rect(link),count=link.querySelector('.nav-count'),icon=link.querySelector(':scope > svg.icon');
const style=getComputedStyle(link);
const content=(icon?rect(icon).width+parseFloat(style.columnGap||0):0)+(()=>{
const range=document.createRange();const text=[...link.childNodes].find(item=>item.nodeType===Node.TEXT_NODE&&item.textContent.trim());
if(!text)return 0;range.selectNodeContents(text);return range.getBoundingClientRect().width;})();
const next=links[index+1];let gapAfter=null;
if(next){const other=rect(next);gapAfter=px(other.top)===px(box.top)?{axis:'x',size:px(other.left-box.right)}:{axis:'y',size:px(other.top-box.bottom)};}
return {text:textOf(link,'.nav-count'),current:link.getAttribute('aria-current'),
...layout(link),...edges(link),...font(link),minHeight:style.minHeight,height:px(box.height),
fullWidth:px(box.width)===px(rect(link.parentElement).width),
hugs:Math.abs(box.width-content-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight))<=1.5,
gapAfter,icon:iconOf(link),
count:count?{text:count.textContent,shown:shown(count),...font(count),...css(count,'marginInlineStart')}:null};
}
// Every subsection is null when its element is missing, so a removed node is reported by its path
// (for example `chrome.breadcrumbs`) instead of failing the measurement.
const when=(node,measure)=>node?measure(node):null;
// The accessible text of a node: its text without aria-hidden descendants.
const accessibleText=node=>textOf(node,'[aria-hidden="true"]');
function itemOf(node,actions) {
const base={tag:node.tagName.toLowerCase(),className:node.className,shown:shown(node)};
if(!base.shown)return base;
const box=relative(node,actions);
return {...base,text:textOf(node,'select, .count'),...font(node),...edges(node),...layout(node),
left:box.left,top:box.top,width:box.width,height:box.height,
count:node.querySelector('.count')?.textContent??null,
select:when(node.querySelector('select'),select=>({options:[...select.options].map(option=>[option.value,option.textContent]),
label:select.getAttribute('aria-label'),labelled:select.labels?.[0]===node,...font(select),...edges(select),
minHeight:getComputedStyle(select).minHeight,width:px(rect(select).width),height:px(rect(select).height)})),
hiddenSuffix:[...node.querySelectorAll('[aria-hidden="true"]')].map(child=>child.textContent).join('')||null,
whiteSpace:getComputedStyle(node).whiteSpace};
}
function sidebarOf() {
return when(sidebar,()=>({...layout(sidebar),...edges(sidebar),
...css(sidebar,'top','overflowY','borderInlineEndWidth'),width:px(rect(sidebar).width),
fillsViewport:Math.abs(rect(sidebar).height-innerHeight)<=1}));
}
function bottomOf(bottom) {
// In the column sidebar the bottom group is pinned to the end by `margin-top: auto`, whose used value is only
// the free space above it, so the pin (its inset from the sidebar's padding edge) is recorded instead.
const sidebarStyle=getComputedStyle(sidebar);
const pinned=sidebarStyle.display==='flex';
const {marginTop,...rest}=edges(bottom);
return {...rest,...css(bottom,'display'),marginTop:pinned?'auto':marginTop,
bottomInset:pinned?px(rect(sidebar).bottom-rect(bottom).bottom-parseFloat(sidebarStyle.paddingBottom)):null};
}
function topActionsOf(actions,topbar) {
const style=getComputedStyle(topbar);
return {...layout(actions),...edges(actions),width:px(rect(actions).width),height:px(rect(actions).height),
fullWidth:Math.abs(rect(actions).width-(rect(topbar).width-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)))<=1,
rightInset:px(rect(topbar).right-rect(actions).right),
items:[...actions.children].map(node=>itemOf(node,actions))};
}
function chromeOf() {
if(!sidebar&&!workspace)return null;
const shell=document.querySelector('.app-shell');
const brand=sidebar?.querySelector('.brand');
const topbar=workspace?.querySelector('.topbar');
const footer=workspace?.querySelector('.app-footer');
return {
title:document.title,
skip:when(document.querySelector('a.skip'),skip=>({text:skip.textContent.trim(),...css(skip,'position','top','left')})),
body:{...font(document.body),backgroundColor:getComputedStyle(document.body).backgroundColor,
tokens:Object.fromEntries(['--ca-text','--ca-muted','--ca-focus','--ca-accent','--ca-field','--ca-surface','--ca-line',
'--line','--page','--sidebar','--soft-blue','--green'].map(name=>[name,getComputedStyle(document.body).getPropertyValue(name).trim()]))},
shell:when(shell,()=>{const columns=getComputedStyle(shell).gridTemplateColumns.split(' ');
return {display:getComputedStyle(shell).display,columns:columns.length,sidebarColumn:columns.length>1?columns[0]:null,
minHeight:getComputedStyle(shell).minHeight};}),
sidebar:sidebarOf(),
brand:when(brand,()=>({text:textOf(brand),name:accessibleText(brand),href:brand.getAttribute('href')?.replace(/^#\/?/,''),
...relative(brand,sidebar),...font(brand),...layout(brand),...edges(brand),
mark:when(brand.querySelector('.brand-mark'),mark=>({text:textOf(mark),hidden:mark.getAttribute('aria-hidden'),...font(mark),
width:px(rect(mark).width),height:px(rect(mark).height)})),
small:when(brand.querySelector('small'),small=>({text:small.textContent,...font(small),...css(small,'display','marginTop')}))})),
labels:sidebar?[...sidebar.querySelectorAll('.sidebar-label')].map(node=>({text:textOf(node),shown:shown(node),...font(node),...layout(node),...edges(node)})):null,
navs:sidebar?[...sidebar.querySelectorAll('nav')].map(nav=>{const links=[...nav.querySelectorAll(':scope > a')];return {
id:nav.id,label:nav.getAttribute('aria-label'),shown:shown(nav),...layout(nav),...edges(nav),
width:px(rect(nav).width),rows:links.map(navRow)};}):null,
bottom:when(sidebar?.querySelector('.sidebar-bottom'),bottomOf),
connection:when(sidebar?.querySelector('.connection'),connection=>({shown:shown(connection),text:textOf(connection,'small'),
small:connection.querySelector('small')?.textContent??null,...layout(connection),...edges(connection),...font(connection),
smallFont:when(connection.querySelector('small'),font),
dot:when(connection.querySelector('i'),dot=>css(dot,'width','height','borderTopLeftRadius','backgroundColor','marginTop'))})),
topbar:when(topbar,()=>({...layout(topbar),...edges(topbar),...css(topbar,'height','minHeight'),...relative(topbar,workspace),
fullWidth:px(rect(topbar).width)===px(rect(workspace).width)})),
breadcrumbs:when(topbar?.querySelector('.breadcrumbs'),crumbs=>({shown:shown(crumbs),text:textOf(crumbs),...layout(crumbs),...font(crumbs),
parts:[...crumbs.querySelectorAll('span')].map(span=>({text:span.textContent,...font(span)})),
...(shown(crumbs)?{left:relative(crumbs,topbar).left,middle:px(rect(crumbs).top+rect(crumbs).height/2-rect(topbar).top)}:{})})),
topActions:when(topbar?.querySelector('.top-actions'),actions=>topActionsOf(actions,topbar)),
main:when(main,()=>({tag:main.tagName.toLowerCase(),tabIndex:main.tabIndex,
...css(main,'paddingTop','paddingRight','paddingBottom','paddingLeft','maxWidth','marginLeft','marginRight')})),
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...css(footer,'height'),width:px(rect(footer).width),
fullWidth:px(rect(footer).width)===px(rect(workspace).width),
spans:[...footer.querySelectorAll(':scope > span')].map(span=>({text:span.textContent.replace(/\s+/g,' ').trim(),...font(span),height:px(rect(span).height)})),
buttons:[...footer.querySelectorAll('button')].map(button=>({text:button.textContent.trim(),...font(button),...edges(button),
minHeight:getComputedStyle(button).minHeight,height:px(rect(button).height)}))})),
};
}
function headingOf() {
const heading=main?.querySelector(':scope > .heading');
if(!heading)return null;
const copy=heading.firstElementChild,h1=copy?.querySelector('h1'),description=copy?.querySelector('p'),actions=heading.querySelector('.heading-actions');
return {...layout(heading),...edges(heading),height:px(rect(heading).height),
copy:when(copy,()=>({tag:copy.tagName.toLowerCase(),...layout(copy)})),
h1:when(h1,()=>({id:h1.id,tabIndex:h1.tabIndex,text:h1.textContent,...font(h1),...edges(h1)})),
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
actions:when(actions,()=>({...layout(actions),...edges(actions),
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
}
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
const classes=node=>[...node.classList].sort().join(' ');
const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)});
/** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */
function controlOf(node,origin) {
const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)};
if(!base.shown)return base;
const style=getComputedStyle(node),input=node.querySelector('input');
// A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either.
const buttons=[...node.querySelectorAll(':scope > button')];
return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null,
primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin),
...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis,
icon:iconOf(node),
options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null,
input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'),
...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})),
// Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff.
exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}};
}
function rowOf(row) {
return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))};
}
const gallery=main?.querySelector(':scope > .gallery-layout');
const body=gallery?.querySelector(':scope > .gallery-body');
const grid=body?.querySelector(':scope > .image-grid');
function captionOf(caption) {
return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body),
// A span's width follows its text, so only its line box is recorded (the D17 date's text differs).
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span),
top:relative(span,caption).top,height:relative(span,caption).height}))};
}
function gridOf() {
const style=getComputedStyle(grid);
return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length,
...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length};
}
function cardOf(card) {
const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img');
const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info');
const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions');
const h3=copy?.querySelector('h3'),p=copy?.querySelector('p');
return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid),
exact:{left:rect(card).left-rect(grid).left,top:rect(card).top-rect(grid).top,height:rect(card).height},
...edges(card),...css(card,'boxShadow','overflow','display'),
art:when(art,()=>({...relative(art,card),...css(art,'position')})),
open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})),
img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img),
...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})),
// An overlay item's horizontal margin may be `auto`, whose used value is the free space beside it: it is kept
// to the pixel, and the item's place is also recorded as its inset from the overlay's right edge.
overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays),
...css(overlays,'display','pointerEvents'),...relative(overlays,card),
items:[...overlays.children].map(node=>{const item=controlOf(node,card);
return item.shown?{...item,marginLeft:`${px(parseFloat(item.marginLeft))}px`,marginRight:`${px(parseFloat(item.marginRight))}px`,
rightInset:px(rect(overlays).right-rect(node).right)}:item;})})),
info:when(info,()=>({...layout(info),...edges(info),...relative(info,card),
// The text and the actions are placed in the card (what is seen), so a centred list row compares by place.
// The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines
// of 16.5px and 13.5px add up exactly.
copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})),
h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3),
...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})),
// `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can
// be shown to fit it on one line.
p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{
const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content';
p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})),
actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'),
buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
}
function continuationOf(continuation) {
return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body),
items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))};
}
function panelOf() {
const panel=gallery.children[1];
// The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here.
const {height,...place}=relative(gallery,main);
return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place,
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)};
}
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};
}
+141 -59
View File
@@ -1,5 +1,9 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs';
// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's
// "Inspector recipe", which names the negative variant.
const POSITIVE_TITLE='Inspector portrait 猫 😀';
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));});
@@ -26,7 +30,10 @@ test('inspection facts survive actual API to browser transport', async ({ page,
const complete = result.complete;
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
// Differing branches show what the legacy parser shows: the first sampler's seed.
expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true);
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
@@ -68,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: 'Inspector recipe', category: 'Inspection', tags: ['fixture', 'geometric'] });
expect(result.library).toMatchObject({ status: 'linked', metadata_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);
@@ -83,7 +90,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
});
const inspector=page=>page.locator('.image-details');
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .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();
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
@@ -95,7 +102,7 @@ async function downloadText(page,action) {
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:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
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');
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
@@ -171,7 +178,7 @@ 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.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click();
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();
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');
@@ -191,7 +198,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
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:'Inspector recipe',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('.image-preview .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('.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);
});
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
@@ -224,7 +231,7 @@ 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-open').click();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,'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();
@@ -259,9 +266,9 @@ test.describe('full inspector coarse pointer',()=>{
}
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
}
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) {
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);
}
@@ -269,55 +276,130 @@ test.describe('full inspector coarse pointer',()=>{
});
});
const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true});
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('.image-preview-position');
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 expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
}
// #249 C6: the concept viewer shell (h84–103, a889–926, s1008–1058, ≤700 s1456–1474), measured by one function in the
// concept and in production. #viewer-stage and #viewer-details content is not part of the shell (C8 owns the details).
const VIEWER_SHELL=()=>{
const dialog=document.querySelector('dialog#viewer-dialog');
const describe=node=>`${node.tagName.toLowerCase()}${node.id?`#${node.id}`:''}${[...node.classList].sort().map(value=>`.${value}`).join('')}`;
const paths=[];
const walk=(node,prefix)=>{for(const child of node.children){const path=`${prefix} > ${describe(child)}`;paths.push(path);if(!child.matches('#viewer-stage,#viewer-toolbar,#viewer-details'))walk(child,path);}};
walk(dialog,describe(dialog));
const box=node=>{const bounds=node.getBoundingClientRect();return [bounds.x,bounds.y,bounds.width,bounds.height].map(Math.round);};
const toolbar=dialog.querySelector('#viewer-toolbar');
const part=selector=>dialog.querySelector(selector);
return {open:dialog.open,labelledBy:dialog.getAttribute('aria-labelledby'),paths,
title:part('#viewer-title').textContent.trim(),subtitle:part('.dialog-header > div > p').textContent.trim(),
close:{text:part('#viewer-close').textContent.trim(),label:part('#viewer-close').getAttribute('aria-label')},
details:part('#viewer-details').getAttribute('aria-label'),
toolbar:[...toolbar.children].map(node=>({tag:node.tagName.toLowerCase(),text:node.textContent.trim(),size:box(node).slice(2)})),
geometry:Object.fromEntries(['dialog','.dialog-header','#viewer-title','#viewer-close','.viewer-layout','.viewer-left','#viewer-details']
.map(selector=>[selector,box(selector==='dialog'?dialog:part(selector))])),
toolbarBox:box(toolbar),stageBox:box(part('#viewer-stage'))};
};
test('viewer matches the concept shell and toolbar order',async({page,request})=>{
await seedConcept(request);
for(const [width,height] of SIZES) {
await page.setViewportSize({width,height});
await openConcept(page,'images','dark');
const card=page.locator('#image-grid > article.media-card').nth(1).locator('.image-open');
const filename=(await card.getAttribute('aria-label')).replace(/^Open /,'');
await card.click();await expect(page.locator('#viewer-dialog .viewer-canvas img')).toBeVisible();
const concept=await page.evaluate(VIEWER_SHELL);
expect(concept.toolbar.map(control=>control.text)).toEqual(['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →']);
await openProduction(page,'images','dark');
const opener=page.locator(`article[data-image-key] .image-open[aria-label="Open ${filename}"]`);await opener.click();
const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();await expect(viewer.locator('#viewer-title')).toHaveText(concept.title);
await expect(viewer.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
// The dialog is named by its title, as the concept's aria-labelledby does.
await expect(page.getByRole('dialog',{name:concept.title,exact:true})).toBeVisible();
const production=await page.evaluate(VIEWER_SHELL);
// C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
const appended=production.toolbar.slice(9);
expect(appended.map(control=>control.tag)).toEqual(['p','p','p']);
await expect(viewer.locator('#viewer-toolbar > p[role="status"]')).toHaveText(new RegExp(`^\\d+ of \\d+ loaded: ${filename.replace(/[\\^$.*+?()[\]{}|]/g,'\\$&')}$`));
expect(production.toolbar[7].tag).toBe('a');
production.toolbar=production.toolbar.slice(0,9).map((control,index)=>index===7?{...control,tag:'button'}:control);
// #249 D34: Viewer.js's own `.viewer-toolbar` rule makes the concept's toolbar absolute across the dialog's
// bottom edge, over the details; production keeps it in `.viewer-left` under the stage.
const [leftX,leftY,leftWidth,leftHeight]=production.geometry['.viewer-left'],[barX,barY,barWidth,barHeight]=production.toolbarBox;
// Inside the left box's 1px border (boxes are rounded to whole pixels).
for(const [actual,expected] of [[barX,leftX+1],[barWidth,leftWidth-2],[barY+barHeight,leftY+leftHeight-1],[production.stageBox[1]+production.stageBox[3],barY]])
expect(Math.abs(actual-expected),`${width}x${height} toolbar inside .viewer-left`).toBeLessThanOrEqual(1);
expect(concept.toolbarBox[2]).toBeGreaterThan(concept.geometry['.viewer-left'][2]);
for(const shell of [concept,production]) {
delete shell.toolbarBox;delete shell.stageBox;
// At 700px and below the details and the stacked layout grow with the details content, which C8 rebuilds.
if(width<=700) for(const selector of ['#viewer-details','.viewer-layout']) shell.geometry[selector]=shell.geometry[selector].slice(0,3);
}
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
// #249 D34: coarse pointers keep production's 40px square targets, wider than the concept's ×, "−" and "+" (the title beside × narrows by the difference).
for(const shell of [concept,production]) {shell.toolbar=shell.toolbar.map(({tag,text})=>({tag,text}));delete shell.geometry['#viewer-close'];shell.geometry['#viewer-title'].splice(2,1);}
expect(production.close).toEqual(concept.close);
}
expect(production,`${width}x${height}`).toEqual(concept);
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(opener).toBeFocused();
}
});
test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0');
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name');
// #249 C2: Source and Folder live in the concept's "Refine this view"; sort is the toolbar's.
await page.getByRole('button',{name:'Filters',exact:true}).click();
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
await refine.getByLabel('Source',{exact:true}).selectOption('0');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('name');
const more=page.getByRole('button',{name:'Load more images',exact:true});
await expect(page.locator('.image-toolbar')).toContainText('40 of');
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);}
await expect(page.locator('.section-caption')).toContainText('40 of');
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.section-caption')).toContainText(`${n} of`);}
const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json();
const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160);
await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120);
let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;});
await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1});
// A grid page, not the sidebar's `limit=1` Images count.
await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',async route=>{started();await held;await route.continue();},{times:1});
await more.click();await hit;await page.evaluate(()=>scrollTo(0,0));
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`);
await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of');
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-open`);
await opener.click();release();await expect(page.locator('.section-caption')).toContainText('200 of');
const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
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.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
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 preview',exact:true}).click();
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
await expect(opener).toBeFocused();
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1');
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
await page.getByRole('button',{name:'Filters',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');
for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true});
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 preview',exact:true}).click();
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 expect(position(page)).toContainText('of 1 loaded: complete.png');
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
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:');
});
@@ -330,8 +412,8 @@ test('collection viewer uses real public transforms and resets on navigation',as
for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};}
window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}};
});
await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true});
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
await openCollection(page);const modal=mediaDialog(page),controls=modal.locator('#viewer-toolbar');
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator('.viewer-container :is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData}));
await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width);
await controls.getByRole('button',{name:'Zoom out',exact:true}).click();
@@ -374,7 +456,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
}
const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click();
async function readyClockImage(page) {
@@ -403,7 +485,7 @@ test('slideshow exposes exact state labels and respects video playback',async({p
await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop');
await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1);
expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3);
await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
await button.click();await modal.locator('#viewer-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1);
});
@@ -434,7 +516,7 @@ test('slideshow production query and route disposal cancel the active deadline',
await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1);
await page.clock.runFor(3499);
const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
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');
@@ -446,18 +528,18 @@ test('slideshow interaction visibility sender and disposal clear intent',async({
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
for(const action of [
()=>modal.locator('.prompt-positive pre').click(),
()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).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();},
()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(),
()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(),
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
]) {
await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded');
}
for(let mount=0;mount<3;mount++) {
await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await button.click();await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play');
await readyClockImage(page);
}
@@ -488,12 +570,12 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
async function capture(name,viewer=false) {
await page.evaluate(()=>document.fonts.ready);
if(viewer) {
const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
measurements.push({name,...await modal.evaluate(node=>{
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('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')),
toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')),
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};
})});
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
@@ -503,23 +585,23 @@ 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:'Appearance',exact:true}).selectOption(theme);
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 capture(`inline-${label}`);
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
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 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 preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
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();
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 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 preview',exact:true}).click();
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
}
const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json');
await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'});
@@ -530,12 +612,12 @@ test('collection viewer preserves original download and rooted retries',async({p
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 expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.');
const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
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');
const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404);
await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied;
await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
await expect.poll(()=>mediaStage(page).locator(':scope > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible();
deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible();
await expect(download).toHaveAttribute('href',original);
@@ -543,10 +625,10 @@ test('collection viewer preserves original download and rooted retries',async({p
expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3);
for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);}
const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png');
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.');
await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click();
await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-open').click();
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();
@@ -561,20 +643,20 @@ test('collection viewer keeps nested dialog focus and route cleanup',async({page
const text=modal.locator('.prompt-positive pre');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('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
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';});
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page);
const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details');
const modal=mediaDialog(page),body=modal.locator('.viewer-layout'),rail=modal.locator('#viewer-details');
// Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom.
const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width});
await settleViewport(1600);
for(const theme of ['dark','light']) {
await page.evaluate(theme=>{const preference=document.getElementById('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
await page.evaluate(theme=>{const preference=document.getElementById('theme');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('#viewer-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.viewer-layout')).columnGap};});
expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'});
await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)});
}
@@ -590,7 +672,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
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=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
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 page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
@@ -604,16 +686,16 @@ test('collection viewer matches approved responsive frame',async({page,request},
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'});
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page);
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport();
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
});
async function currentStageFit(page) {
return page.evaluate(()=>{
const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage');
const instance=window.fitInstances.at(-1),stage=document.querySelector('#viewer-stage');
const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect();
return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData},
image:{width:image.width,height:image.height},
@@ -651,7 +733,7 @@ async function openStageFitFixture(page,request) {
window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement});
});
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden();
await expect(mediaDialog(page).locator('.media-reason')).toBeHidden();
return release;
}
async function attachStageFit(page,testInfo,name) {
@@ -675,7 +757,7 @@ test('collection viewer refits a late wrapped title without replacing current me
const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true});
await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');});
await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release();
await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
await expect(modal.locator('#viewer-title')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit');
await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1);
const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true);
@@ -688,7 +770,7 @@ test('collection viewer refits a late wrapped title without replacing current me
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');
await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);});
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);
await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true);
expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]);
@@ -15,8 +15,7 @@ export const canonicalDifferences = {
'Current-file candidates disclose unknown identity and context membership',
],
remainingApplicationPolish: [
'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
'Shared Viewer stage background and toolbar alignment remain outside #209',
'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
@@ -106,7 +105,7 @@ export async function exerciseCurrentPairSend(page, roles) {
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
} });
});
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
const viewer = page.locator('dialog#viewer-dialog');
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });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' });
+613
View File
@@ -0,0 +1,613 @@
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';
// 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,
// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
// D37: the concept loads 12 images per batch (app.js imageBatchSize) and production one listing page (40), so both are
// compared once the 24 fixture images are all loaded: the concept after its own Load more press.
const FIXTURE_IMAGES=24;
const CONCEPT_BATCH=12;
const PRODUCTION_BATCH=40;
// Load more in production needs more than one listing page: the fixture continues the concept's rounds (two pages).
const LOAD_MORE_EXTRA=2*PRODUCTION_BATCH-FIXTURE_IMAGES;
const CARDS='.gallery-body > .image-grid > .media-card';
const WIDE_SIZES=Object.freeze([[1200,900],[1800,1100]]);
/** D17: "<Today|Yesterday|weekday> · <Month D>" of the newest file (the fixture's own mtime), in the test's UTC zone. */
function captionDay(seconds,now=new Date()) {
const day=value=>Date.UTC(value.getUTCFullYear(),value.getUTCMonth(),value.getUTCDate());
const date=new Date(seconds*1000),days=Math.round((day(now)-day(date))/86400000);
const name=days===0?'Today':days===1?'Yesterday':date.toLocaleDateString('en-US',{weekday:'long',timeZone:'UTC'});
const options={month:'long',day:'numeric',timeZone:'UTC',...(date.getUTCFullYear()===now.getUTCFullYear()?{}:{year:'numeric'})};
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}"]`));
/**
* `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 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};
}
/**
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
* - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
*/
function listInfo(card,actionsWidth) {
const {info}=card,{copy,p}=info;
const width=card.width-2*parseFloat(card.borderTopWidth)-card.art.width;
const column=width-parseFloat(info.paddingLeft)-parseFloat(info.paddingRight)-parseFloat(info.columnGap)-actionsWidth;
const line=parseFloat(p.lineHeight);
const pHeight=p.height>line&&p.maxContent<=column?line:p.height;
const copyHeight=copy.height-(p.height-pHeight);
const natural=Math.max(copyHeight,info.actions.height)+parseFloat(info.paddingTop)+parseFloat(info.paddingBottom);
// The row's inner box is the stretched concept info: its top is the card border, its height the art's.
return {info:{width,top:Math.round(info.top+(info.height-natural)/2),height:Math.round(natural)},
copy:{height:copyHeight,top:Math.round(info.top+(info.height-copyHeight)/2)},p:{height:pHeight},
actionsLeft:info.left+width-parseFloat(info.paddingRight)-actionsWidth};
}
/** Recorded Images 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 mapImagesConcept(reference,{manifest,now=new Date()}) {
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
// D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item);
// D29: no "Illustrative previews" sample tag.
const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
filterRow.items=filterRow.items.map(item=>{
// D32: the layout buttons may wrap at extreme zoom (they never do while they fit).
if(item.className==='segmented')return {...item,flexWrap:'wrap'};
// D14: the scope chip may shrink and wrap a long source or folder name inside itself.
if(item.tag==='span'&&item.className==='chip')return {...item,minWidth:'0px'};
return item;
});
// A shorter toolbar or filter row moves what follows it up by as much.
const toolbarRise=reference.toolbar.height-toolbar.height;
filterRow.top-=toolbarRise;
const rise=toolbarRise+reference.filterRow.height-filterRow.height;
const list=reference.grid.className.split(' ').includes('list-grid');
const cards=reference.cards.map(card=>{
const filename=card.open.label.replace(/^Open /,'');
const actions=card.info.actions;
const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
// D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
const freed=tag?tag.width+parseFloat(actions.columnGap):0;
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
const actionsWidth=actions.width-freed;
const listRow=list?listInfo(card,actionsWidth):{};
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+$/,'')},
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},
actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
buttons:actions.buttons.filter(button=>button!==tag)}}};
});
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
// D35: the count line uses the singular for one.
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);
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. */
function mapContinuation(continuation,numbers=null) {
return {...continuation,flexWrap:'wrap',items:continuation.items.map(item=>item.tag!=='p'?item:{...item,
// D37: a row with more to load counts production's own page ("N of M"), not the concept's 12-image batch.
text:countCopy(numbers?item.text.replace(/^\d+ of \d+/,`${numbers.loaded} of ${numbers.total}`):item.text)})};
}
/** D37: the Load more row before its press, the concept's at 12 of 24 against production's at its own page. */
function compareLoadingRow(reference,actual,count) {
const [,loaded,total]=count.match(/^(\d+) of (\d+)/);
// The row's place follows the grid above it, whose height is the batch's (D37).
const unplaced=({top,...row})=>stripExact(row);
return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
}
/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
function mapImportConcept(reference) {
return {...reference,
intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder'
?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
}
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.
const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
function compare(reference,actual,manifest,sections=SECTIONS) {
const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertImagesMatch(reference,actual,manifest) {
const diff=compare(reference,actual,manifest);
if(diff.length)throw new Error(`the Images page differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
/**
* D3 (masonry sub-pixel snapping): bounded rendering places masonry cards from the layout arithmetic (media width over
* the file's ratio), while the concept's flow snaps each box to the browser's layout unit (1/64px in Chromium and
* WebKit, 1/60px in Firefox) before measuring the next. So masonry tops and heights are compared unrounded instead,
* within one layout unit per card above in the column (the card's own included); the rounded fields are skipped there.
*/
const MASONRY_ROUNDED=/^cards\.\d+\.(top|height)$/;
function masonryDrift(reference,actual) {
return reference.cards.flatMap((card,index)=>{
const above=reference.cards.slice(0,index).filter(other=>Math.abs(other.exact.left-card.exact.left)<1).length;
const bound=(above+1)/60+1e-6,other=actual.cards[index];
return ['top','height'].filter(key=>!(Math.abs(card.exact[key]-other.exact[key])<=bound))
.map(key=>({path:`cards.${index}.exact.${key}`,reference:card.exact[key],actual:other.exact[key],bound}));
});
}
function compareLayout(layout,reference,mapped,actual) {
const diff=diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(actual));
return layout==='Masonry'?[...diff.filter(item=>!MASONRY_ROUNDED.test(item.path)),...masonryDrift(reference,actual)]:diff;
}
// Two frames, then every running transition (a card image zooming back after the pointer left it) 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 countText=page=>page.locator('#result-count').textContent();
const toastText=page=>page.locator('.toast').textContent();
/** The concept with every fixture image loaded through its own Load more button; returns what the press did. */
async function openConceptLoaded(page,theme,motion) {
await openConcept(page,'images',theme,motion);await page.mouse.move(0,0);await settle(page);
const before=await page.locator(CARDS).count();
const loadingRow=(await inventoryOf(page,'#main')).continuation;
// A dispatched press: scrolling the button into view would let the concept's own observer load the batch first.
// Where the first batch leaves the row in view (1800px, four columns), that observer has already loaded it.
if(before<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
const load={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page),loadingRow};
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page);
return load;
}
async function openProductionLoaded(page,theme,motion,total=FIXTURE_IMAGES) {
await openProduction(page,'images',theme,motion);await page.mouse.move(0,0);
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${Math.min(total,PRODUCTION_BATCH)} of ${total} images`));
await expect(page.locator(CARDS)).toHaveCount(Math.min(total,PRODUCTION_BATCH));
await settle(page);
}
/** The Import dialog's intro and operation rows (D15). */
async function importDialogOf(page) {
await page.locator('.heading-actions').getByRole('button',{name:'Import',exact:true}).click();
const dialog=page.locator('dialog#modal[open]');
await expect(dialog).toBeVisible();
const inventory=await dialog.evaluate(node=>{
const css=(element,...keys)=>{const style=getComputedStyle(element);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const font=element=>css(element,'fontSize','fontWeight','lineHeight','color');
const intro=node.querySelector('#modal-body > p.small');
return {title:node.querySelector('#modal-title').textContent,
intro:intro&&{text:intro.textContent,...font(intro)},
...css(node.querySelector('.operation-list'),'display','rowGap'),
rows:[...node.querySelectorAll('.operation-list > .operation')].map(row=>({title:row.querySelector('h3').textContent,
description:row.querySelector('p').textContent,buttons:[...row.querySelectorAll('button')].map(button=>button.textContent.trim()),
titleFont:font(row.querySelector('h3')),descriptionFont:font(row.querySelector('p')),...css(row,'display','alignItems')}))};
});
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
return inventory;
}
/** The hovered first card image after its transition, then the pointer parked again. */
async function hoverOf(page) {
const open=page.locator(CARDS).first().locator('.image-open');
await open.hover();
// The concept's 0.2s transition (instant under reduced motion) has started and finished.
await settle(page);
const state=await open.locator('img').evaluate(img=>({hovered:img.closest('.image-open').matches(':hover'),
transform:getComputedStyle(img).transform,transitionDuration:getComputedStyle(img).transitionDuration,
transitionProperty:getComputedStyle(img).transitionProperty,hoverNone:matchMedia('(hover: none)').matches}));
await page.mouse.move(0,0);
return state;
}
const activeOf=page=>page.evaluate(()=>{const node=document.activeElement;
return {tag:node?.tagName.toLowerCase()??null,label:node?.getAttribute('aria-label')??null,
card:node?.closest('.media-card')?[...node.closest('.image-grid').children].indexOf(node.closest('.media-card')):null};});
const selectedOf=page=>page.locator(CARDS).evaluateAll(nodes=>nodes.flatMap((node,index)=>node.classList.contains('selected')?[index]:[]));
const LAYOUTS=['List','Masonry','Grid','Large','Compact'];
/** The card boxes in each layout: Grid → List → Masonry → Grid, then the Large and Compact thumbnail sizes. */
async function layoutsOf(page,scope) {
const result={};
for(const layout of LAYOUTS) {
if(['Large','Compact'].includes(layout))await page.locator('#image-size').selectOption(layout.toLowerCase());
else {
await page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true}).click();
await expect(page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true})).toHaveAttribute('aria-pressed','true');
}
await page.mouse.move(0,0);await settle(page);await settle(page);
const inventory=await inventoryOf(page,scope);
result[layout]={grid:inventory.grid,cards:inventory.cards};
}
await page.locator('#image-size').selectOption('comfortable');await settle(page);
return result;
}
const continuationText=page=>page.locator('.infinite-continuation > p').first().textContent();
/** A search: the count line, the continuation and the cards it leaves, then the search cleared again. */
async function searchOf(page,term) {
const before=await countText(page);
await page.locator('#search').fill(term);
await expect.poll(()=>countText(page)).not.toBe(before);
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(FIXTURE_IMAGES);
await settle(page);
const result={count:await countText(page),continuation:await continuationText(page),cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
await page.locator('#search').fill('');
await expect(page.locator('#result-count')).toHaveText(before);
return result;
}
/** The behaviors §7 lists, run with identical selectors in both documents. */
async function behaviorsOf(page,scope) {
const hover=await hoverOf(page);
const second=page.locator(CARDS).nth(1).locator('.image-open');
const opened=await second.getAttribute('aria-label');
await second.click();
const viewer=page.locator('#viewer-dialog[open]');
await expect(viewer).toBeVisible();
const viewerTitle=await page.locator('#viewer-title').textContent();
await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
await settle(page);
const focusAfterViewer=await activeOf(page);
await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click();
await expect.poll(()=>selectedOf(page)).toEqual([2]);
const selected=await selectedOf(page);
const layouts=await layoutsOf(page,scope);
const search=await searchOf(page,'alpine');
// Exactly one image (a unique filename stem): both counted nouns in the singular (D35).
const single=await searchOf(page,'alpine_00003');
return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
}
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
test.setTimeout(240_000);
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;const result={};
for(const motion of MOTIONS) {
const capture=motion==='no-preference';
const conceptLoad=await openConceptLoaded(page,theme,motion);
const reference=await inventoryOf(page,'#main');
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
// The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
const removed={};
for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
const restore=await mutate(page,{remove:[selector]});
removed[key]=(await inventoryOf(page,'#main'))[key];
await restore();
}
const referenceImport=await importDialogOf(page);
const referenceBehaviors=await behaviorsOf(page,'#main');
await openProductionLoaded(page,theme,motion);
const actual=await inventoryOf(page,'#workspace-main');
if(capture)await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
const actualImport=await importDialogOf(page);
const actualBehaviors=await behaviorsOf(page,'#workspace-main');
result[motion]={conceptLoad,reference,removed,actual,diff:compare(reference,actual,manifest),referenceImport,actualImport,
importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
}
// Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
for(const motion of MOTIONS) {
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
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();
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);
}
const path=testInfo.outputPath('images-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('images-geometry',{path,contentType:'application/json'});
for(const [motion,entry] of Object.entries(result)) {
const {conceptLoad:{loadingRow,...conceptLoad},productionLoad,loadingDiff,reference,removed,actual,diff,referenceImport,importDiff,
referenceBehaviors:concept,actualBehaviors:production}=entry;
// The concept itself is what this comparison assumes: its first batch, its sample card and its copy.
expect.soft(conceptLoad,`${motion} concept Load more`).toEqual({before:CONCEPT_BATCH,after:FIXTURE_IMAGES,
toast:`${CONCEPT_BATCH} more images loaded. ${FIXTURE_IMAGES} of ${FIXTURE_IMAGES}.`,count:`${FIXTURE_IMAGES} of ${FIXTURE_IMAGES} images · 6 positive prompts`});
// D37: production appends one listing page, with the concept's toast and count copy (D35 for one).
expect.soft(productionLoad,`${motion} production Load more`).toEqual({before:PRODUCTION_BATCH,after:2*PRODUCTION_BATCH,
toast:countCopy(`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`),
count:countCopy(`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`)});
// D37: the row with more to load, before its press, in both documents.
expect.soft(loadingRow.items.map(item=>item.text),`${motion} concept loading row`).toEqual([`${CONCEPT_BATCH} of ${FIXTURE_IMAGES} images · more load as you scroll`,'Load more images']);
expect.soft(loadingDiff,`${motion} loading row differences after D32, D35, D37`).toEqual([]);
// D11: the concept's subtitle is the fixture's own "W × H · PNG · Seed N".
const first=manifest.images[0];
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
// The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
// sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
expect.soft(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
`${motion} toolbar without Save view`).toEqual([]);
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
`${motion} filter row without the sample tag`).toEqual([]);
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
expect.soft(importDiff,`${motion} Import dialog differences after D15`).toEqual([]);
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
// Hover (C4): the concept's 1.03 zoom over 0.2s, instant under reduced motion; D18: none where there is no hover.
expect.soft(concept.hover,`${motion} concept hover`).toMatchObject({hovered:true,transform:'matrix(1.03, 0, 0, 1.03, 0, 0)',
transitionDuration:motion==='reduce'?'0s':'0.2s'});
expect.soft(production.hover,`${motion} hover`).toEqual(production.hover.hoverNone?{...concept.hover,transform:'none'}:concept.hover);
// The 2nd card's image opens the viewer titled "<title> · <filename>".
expect.soft(concept.viewerTitle,`${motion} concept viewer title`).toBe(`${reference.cards[1].info.h3.text} · ${concept.opened.replace(/^Open /,'')}`);
expect.soft(production.viewerTitle,`${motion} viewer title`).toBe(concept.viewerTitle);
// Escape returns focus to the image that opened the viewer.
expect.soft(production.focusAfterViewer,`${motion} focus after the viewer`).toEqual({tag:'button',label:concept.opened,card:1});
// The concept re-renders its grid when the viewer closes (app.js 1737-1743), so its focus falls to the body;
// §7 and D19 keep production's restore, which is what is asserted above.
// Info on the 3rd card moves `.media-card.selected` there.
expect.soft(production.selected,`${motion} selection after Info`).toEqual(concept.selected);
// List (160×95 art) and Masonry (--media-ratio media) as the concept lays them out.
expect.soft(concept.layouts.List.cards[0].img,`${motion} concept list art`).toMatchObject({width:160,height:95});
for(const layout of LAYOUTS) {
const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{manifest});
expect.soft(compareLayout(layout,concept.layouts[layout],mapped,production.layouts[layout]),`${motion} ${layout} layout`).toEqual([]);
}
for(const card of concept.layouts.Masonry.cards) {
const [w,h]=card.ratio.split('/').map(Number);
expect.soft(Math.abs(card.img.width/card.img.height-w/h),`${motion} concept masonry ratio ${card.open.label}`).toBeLessThan(0.02);
}
// Searches leave the same images, count line and continuation in both documents (D35 for one).
const mappedSearch=search=>({...search,count:countCopy(search.count),continuation:countCopy(search.continuation)});
expect.soft(production.search,`${motion} search alpine`).toEqual(mappedSearch(concept.search));
expect.soft(concept.single,`${motion} concept single result`).toMatchObject({count:'1 of 1 images · 1 positive prompts',cards:['Open alpine_00003.png']});
expect.soft(production.single,`${motion} search for one image`).toEqual(mappedSearch(concept.single));
expect.soft(production.single.count,`${motion} singular count line`).toBe('1 of 1 image · 1 positive prompt');
}
});
// 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)=>{
await page.setViewportSize({width:1600,height:1100});
const manifest=testInfo.manifest;
await openConceptLoaded(page,'dark','no-preference');
const reference=await inventoryOf(page,'#main');
await openProductionLoaded(page,'dark','no-preference');
const measure=()=>inventoryOf(page,'#workspace-main');
assertImagesMatch(reference,await measure(),manifest);
const restoreCss=await mutate(page,{css:'.media-info { padding: 4px }'});
const cssMutated=await measure();
expect(()=>assertImagesMatch(reference,cssMutated,manifest)).toThrow(/cards\.0\.info\.paddingTop/);
await restoreCss();await settle(page);
assertImagesMatch(reference,await measure(),manifest);
await mutate(page,{remove:['[aria-label^="Inspect"]']});
const domMutated=await measure();
expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/);
});
// Grid, cards and panel at the other concept breakpoints: 2 columns and the 275px panel at 1200, 4 columns and the
// 310px panel at 1800.
for(const [width,height] of WIDE_SIZES)for(const theme of THEMES)test(`images grid, cards and panel match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
await openConceptLoaded(page,theme,'no-preference');
const reference=await inventoryOf(page,'#main');
await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
await openProductionLoaded(page,theme,'no-preference');
const actual=await inventoryOf(page,'#workspace-main');
await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
expect(reference.grid.columns,'concept columns').toBe(width<=1250?2:4);
expect(reference.panel.box.width,'concept panel').toBe(width<=1250?275:310);
expect(compare(reference,actual,manifest,['grid','cards','panel'])).toEqual([]);
});
// D35 for the toast and the continuation: production's Load more appending exactly one image, against the concept's
// own toast and row copy (app.js 489, 517-521) with production's numbers (D37).
test('one appended image reads in the singular',async({page,request})=>{
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
test.setTimeout(120_000);
await page.setViewportSize({width:1600,height:1100});
const total=PRODUCTION_BATCH+1;
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
await openProductionLoaded(page,'dark','no-preference',total);
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
// A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
expect(countCopy('1 more images loaded. 41 of 41.')).toBe('1 more image loaded. 41 of 41.');
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
});
// The same singular copy when the page's own observer loads the one image: the user only scrolls (no press).
test('one image appended by scrolling reads in the singular',async({page,request})=>{
test.setTimeout(120_000);
await page.setViewportSize({width:1600,height:1100});
const total=PRODUCTION_BATCH+1;
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
await openProductionLoaded(page,'dark','no-preference',total);
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
});
// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line) form the first row at 390px, where
// every concept subtitle wraps: their row is as tall as the wrapped rows, and every concept row moves down by one.
test('one footer height for the collection (D3)',async({page,request})=>{
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
test.setTimeout(120_000);
await page.setViewportSize({width:390,height:844});
await openConceptLoaded(page,'dark','no-preference');
const reference=await inventoryOf(page,'#main');
expect(reference.grid.columns).toBe(2);
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
const manifest=await seedConcept(request,{seedless_images:2});
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
const actual=await inventoryOf(page,'#workspace-main');
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
const pitch=reference.cards[2].top-reference.cards[0].top;
expect(actual.cards.map(box)).toEqual([box(reference.cards[0]),box(reference.cards[1]),
...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
const seedless=actual.cards.slice(0,2);
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
// Their own footer is one line shorter than the row it reserves.
for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
});
+1 -1
View File
@@ -18,7 +18,7 @@ test.beforeEach(async({page,request})=>{
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`editor state captures for maintainer acceptance ${width} ${theme}`,async({page},testInfo)=>{
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:'Appearance',exact:true}).selectOption(theme);
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');
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
+55 -11
View File
@@ -4,7 +4,7 @@ import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/inde
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('region',{name:'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:/^Open /}).click();
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});
test.beforeEach(async({page,request,browserName})=>{
@@ -45,10 +45,10 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK();
const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0'));
await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed');
await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
await page.locator('article[data-image-key="0:shared/same.png"] .image-open').click();const preview=page.locator('dialog#viewer-dialog');
const message='The source changed. Refresh the image collection before inspecting this file.';
await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0);
const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true});
const original=preview.locator('#viewer-toolbar').getByLabel('Download original',{exact:true});
await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./);
let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0);
await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden();
@@ -111,17 +111,49 @@ test('sender requires explicit destination, previews full changes and acknowledg
await chosen.getByRole('button',{name:'Close',exact:true}).click();
await page.evaluate(()=>scrollTo(0,0));
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
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 expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
await page.getByRole('button',{name:'Close preview',exact:true}).click();
await page.getByRole('button',{name:'Close image viewer',exact:true}).click();
await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
// D6: ComfyUI ↗ is a link to the canvas; on Images with a selected file whose two roles resolved, a plain click opens
// the acknowledged pair send instead (concept app.js 1750-1757).
test('ComfyUI ↗ opens the pair send for a selected resolved image, else follows the link',async({page,context})=>{
const host=await fakeHost(context,page);
try {
await page.goto('/prompt_manager/workspace#/overview');
const link=page.locator('header.topbar').getByRole('link',{name:'ComfyUI',exact:true});
await expect(link).toHaveAttribute('href','/');
// Record whether the link's own handler let the navigation through, then stop it so the page stays.
await page.evaluate(()=>{window.followed=[];document.addEventListener('click',event=>{if(!event.target.closest?.('.canvas-link'))return;window.followed.push(!event.defaultPrevented);event.preventDefault();});});
await link.click();await expect(dialog(page)).toHaveCount(0);
await page.locator('nav [data-route="images"]').click();await expect(page.locator('article[data-image-key]').first()).toBeVisible();
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 link.click();
const sender=await chooseDestination(page);
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
for(const text of ['Cat portrait','blurry, watermark'])await expect(sender).toContainText(text);
expect(await host.evaluate(()=>window.applied)).toEqual([]);
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:'Cat portrait',negative:'blurry, watermark'}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(dialog(page)).toHaveCount(0);await expect(link).toBeFocused();
// Another page has no image selection, so the link is a plain link again.
await page.locator('nav [data-route="library"]').click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await link.click();await expect(dialog(page)).toHaveCount(0);
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false,true]);
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
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 {
@@ -195,8 +227,9 @@ test('filtering away a selected image aborts its pending metadata request',async
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
const baseline=await page.evaluate(()=>window.inspectionAborts);
await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('no-such-file');
await expect(page.getByText('No matching images · 0 images',{exact:true})).toBeVisible();
await expect(page.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
} finally {release();}
});
@@ -215,10 +248,10 @@ test('responsive gallery defers observed size writes and still fits the new view
};
});
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.image-card')).toHaveCount(4);
await expect(page.locator('.media-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(383.75,0);
await expect.poll(()=>page.locator('.media-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(384.4,0); // the concept's 1.33 media plus the measured info row
expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
});
@@ -232,6 +265,17 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
});
test('unresolved roles name the blocking node as text and offer no prompt actions',async({page})=>{
const line='Prompt uses a node the inspector does not support (<img src=x onerror="window.injected=1">).';
await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{
positive:{status:'unresolved',text:null,source:null,message:line},negative:{status:'unresolved',text:null,source:null,message:line},
}}}));
await page.goto(workspace);await select(page,'0:uncertain.png');
for (const name of ['Positive','Negative']) await expect(role(details(page),name)).toContainText(line);
await expect(details(page).locator('img[onerror]')).toHaveCount(0);
expect(await page.evaluate(()=>window.injected)).toBeUndefined();
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})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
+40
View File
@@ -14,6 +14,11 @@ from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
seed_workspace_concept,
)
from tests.browser.workspace_visual_fixture import seed_workspace_visual
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
from tests.browser.workspace_history_fixture import (
@@ -128,6 +133,7 @@ def create_app(root: Path) -> web.Application:
) -> None:
with api.db.model.get_connection() as conn:
for table in (
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -379,6 +385,40 @@ def create_app(root: Path) -> web.Application:
)
return web.json_response({"success": True})
@routes.post("/__test__/concept/reset")
async def concept_reset(request: web.Request) -> web.Response:
"""Seed the concept-shaped fixture (D30); only a bounded image count."""
nonlocal history_fixture
data = await request.json() if request.can_read_body else {}
limits = {
"extra_images": CONCEPT_MAX_EXTRA_IMAGES,
"seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
}
if (
request.query
or not isinstance(data, dict)
or set(data) - set(limits)
or any(
isinstance(data.get(key, 0), bool)
or not isinstance(data.get(key, 0), int)
or not 0 <= data.get(key, 0) <= limit
for key, limit in limits.items()
)
):
raise web.HTTPBadRequest()
history_commands.reset()
library_editor_commands.reset()
history_fixture = None
manifest = await api._run_in_executor(
lambda: seed_workspace_concept(
api,
root,
extra_images=data.get("extra_images", 0),
seedless_images=data.get("seedless_images", 0),
)
)
return web.json_response({"success": True, **manifest})
@routes.post("/__test__/add-nested-image")
async def add_nested_image(request: web.Request) -> web.Response:
destination = root / "output" / "project" / "day" / "new-output.png"
+256
View File
@@ -0,0 +1,256 @@
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,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
// 1600x1100 and 390x844, normal and reduced motion. Paired captures are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
// Routes whose shell is compared. The concept and production share route names for these three pages.
const ROUTES=Object.freeze([['images','Images'],['library','Prompt library'],['tags','Tags']]);
// The heading's content (description copy, actions) belongs to each page's own PR (C1, E1, F1) and is compared there;
// Tags & categories is already rebuilt (#245), so its heading is compared in full.
const FULL_HEADING=new Set(['tags']);
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity'));
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
function packedLefts(items,width,justify,gap) {
const total=items.reduce((sum,item)=>sum+item.width,0)+gap*(items.length-1);
let start=0,spacing=gap;
if(justify==='flex-end'||justify==='end')start=width-total;
else if(justify==='center')start=(width-total)/2;
else if(justify==='space-between'&&items.length>1)spacing=gap+(width-total)/(items.length-1);
let x=start;
return items.map(item=>{const left=Math.round(x);x+=item.width+spacing;return left;});
}
/** The top-actions row once removed items are gone: a centered flex row, so its size and item offsets follow. */
function remainingTopActions(actions) {
const kept=actions.items.filter(item=>!removedItem(item));
const shown=kept.filter(item=>item.shown);
const height=Math.max(...shown.map(item=>item.height));
const gap=parseFloat(actions.columnGap);
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
// At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
return {...actions,height,width,
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
}
/** The footer once its button is gone: paddings plus the remaining spans along the footer's flex direction. */
function remainingFooter(footer,spans) {
const content=footer.flexDirection==='column'
?spans.reduce((sum,span)=>sum+span.height,0)+parseFloat(footer.rowGap)*(spans.length-1)
:Math.max(...spans.map(span=>span.height));
return `${parseFloat(footer.paddingTop)+parseFloat(footer.paddingBottom)+content}px`;
}
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
function mapShellConcept(reference) {
const {chrome}=reference;
const navs=chrome.navs
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
.map(nav=>{
if(nav.id!=='system-nav')return nav;
const system=rowNamed(nav,'System');
return {...nav,rows:[
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
system,
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
{...system,text:'Overview',current:null,gapAfter:null,icon:{...system.icon,d:OVERVIEW_GLYPH}},
]};
});
return {...reference,chrome:{...chrome,
// D29: no prototype suffix in the document title.
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
// D28: only the WORKSPACE group label remains.
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
navs,
connection:{...chrome.connection,
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
small:'Your library, on your server',
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
// centered offsets follow from the concept's own item sizes.
topActions:remainingTopActions(chrome.topActions),
footer:(()=>{
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
// (a prototype review page); the footer then holds only its span.
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
})(),
},
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
}
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
function headingFrame(heading) {
if(!heading)return heading;
const {height,description,actions,...frame}=heading;
const {text:copy,...descriptionTokens}=description??{};
const {buttons,stacked,...actionTokens}=actions??{};
return {...frame,description:description?descriptionTokens:null,actions:actions?actionTokens:null};
}
function compare(route,reference,actual) {
const expected=mapShellConcept(reference);
const heading=FULL_HEADING.has(route);
return [
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
.map(item=>({...item,path:`heading.${item.path}`})),
];
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertShellMatches(route,reference,actual) {
const diff=compare(route,reference,actual);
if(diff.length)throw new Error(`the shell differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
/** Keyboard focus rings on the first WORKSPACE nav link and the first heading button. */
async function ringsOf(page,mainSelector) {
// Any key press makes the next programmatic focus keyboard-modal (`:focus-visible`); WebKit does not Tab to links.
await page.locator('#page-title').focus();await page.keyboard.press('Tab');
const button=page.locator(`${mainSelector} .heading-actions button`).first();
await expect(button).toBeFocused();
const headingButton=await focusRingOf(button);
const link=page.locator('#primary-nav a').first();await link.focus();
const navLink=await focusRingOf(link);
await link.blur();
return {navLink,headingButton};
}
/** Hover colors of the first non-current WORKSPACE row, the Theme select and the ComfyUI link. */
async function hoversOf(page) {
return {
navRow:await hoverStateOf(page,page.locator('#primary-nav a:not([aria-current])').first()),
theme:await hoverStateOf(page,page.getByRole('combobox',{name:'Theme',exact:true})),
canvasLink:await hoverStateOf(page,page.locator('.topbar .canvas-link')),
};
}
/** A viewport capture with the footer (and, at 1600, the sidebar's bottom group) in view. */
async function captureFooter(page,testInfo,name) {
await page.locator('.app-footer').scrollIntoViewIfNeeded();
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
await captureCanonicalState(page,testInfo,name);
}
test.beforeEach(async({request})=>{await seedConcept(request);});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});const result={};
for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) {
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
const reference=await inventoryOf(page,'#main');
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`);
const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null;
const referenceHovers=await hoversOf(page);
if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`);
await openProduction(page,route,theme,motion);await page.mouse.move(0,0);
const actual=await inventoryOf(page,'#workspace-main');
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`);
const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null;
const actualHovers=await hoversOf(page);
if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`);
result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings,
referenceHovers,actualHovers};
}
const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('shell-geometry',{path,contentType:'application/json'});
for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) {
// The concept itself is what this comparison assumes: its sample counts and its shell controls.
expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`)
.toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]);
expect.soft(reference.chrome.topActions.items.map(item=>item.className||item.tag),`${key} concept top actions`)
.toEqual(['concept-badge','button','theme-label','canvas-link']);
expect.soft(reference.chrome.breadcrumbs.parts.at(-1).text,`${key} concept breadcrumb`).toBe(breadcrumb);
expect.soft(reference.chrome.title,`${key} concept title`).toBe(`${breadcrumb} · Prompt Manager concept`);
expect.soft(actual.chrome.title,`${key} production title`).toBe(`${breadcrumb} · Prompt Manager`);
expect.soft(reference.heading.h1.text,`${key} concept page title`).toBe(route==='tags'?'Tags & categories':breadcrumb);
// The concept's own nav-row hover (style.css:183-186) is what production is held to.
expect.soft(referenceHovers.navRow,`${key} concept nav-row hover`).toMatchObject({hovered:true,color:reference.chrome.body.color});
expect.soft(actualHovers,`${key} hover states`).toEqual(referenceHovers);
expect.soft(diff,`${key} differences after the recorded deviations`).toEqual([]);
expect.soft(actual.noPageScroll,`${key} no page-level horizontal scroll`).toBe(true);
if(referenceRings) {
// The toolkit's button ring (2px offset) wins over the concept's 3px for buttons, in the concept too (#245).
expect.soft(referenceRings.headingButton,`${key} concept heading-button ring`).toMatchObject({focused:true,visible:true,outlineStyle:'solid',outlineWidth:'2px'});
expect.soft(referenceRings.navLink,`${key} concept nav-link ring`).toMatchObject({focused:true,outlineStyle:'solid',outlineWidth:'2px',outlineOffset:'3px'});
expect.soft(actualRings,`${key} focus rings`).toEqual(referenceRings);
}
}
});
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
const reference=mapShellConcept(await inventoryOf(page,'#main'));
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
expect(diffInventory(reference.chrome,(await inventoryOf(page,'#workspace-main')).chrome)).toEqual([]);
await page.locator('.breadcrumbs').evaluate(node=>node.remove());
await page.locator('.connection small').evaluate(node=>node.remove());
const actual=await inventoryOf(page,'#workspace-main');
expect(actual.chrome.breadcrumbs).toBeNull();
expect(actual.chrome.connection.smallFont).toBeNull();
const paths=diffInventory(reference.chrome,actual.chrome).map(item=>item.path);
expect(paths).toContain('breadcrumbs');
expect(paths).toContain('connection.smallFont');
expect(paths).toContain('connection.small');
});
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');
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();
});
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
// concept never uses) and one DOM mutation (the breadcrumbs removed) are applied to production after an exact match,
// and each must make the comparison fail with the path of what changed.
for(const [width,height] of SIZES)test(`shell comparison detects mutations ${width}x${height}`,async({page})=>{
await page.setViewportSize({width,height});
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
const reference=await inventoryOf(page,'#main');
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
const measure=()=>inventoryOf(page,'#workspace-main');
const clean=await measure();
assertShellMatches('tags',reference,clean);
const restoreCss=await mutate(page,{css:'.sidebar nav a { min-height: 30px }'});
const cssMutated=await measure();
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.minHeight/);
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.height/);
await restoreCss();
assertShellMatches('tags',reference,await measure());
await mutate(page,{remove:['.breadcrumbs']});
const domMutated=await measure();
expect(domMutated.chrome.breadcrumbs).toBeNull();
// The missing section is reported by its own path (A6: absent sections become null), not by a measuring error.
expect(()=>assertShellMatches('tags',reference,domMutated)).toThrow(/(at |, )chrome\.breadcrumbs(,|$)/);
});
@@ -3,7 +3,7 @@
// mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling.
export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs';
export function pageInventoryOf(main, production) {
export function pageInventoryOf(main) {
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const px=value=>Math.round(value);
const visible=node=>node.getClientRects().length>0;
@@ -18,8 +18,8 @@ export function pageInventoryOf(main, production) {
// Rendered line count of a cell's text, so a wrapped short name is reported even when the row height is unchanged.
const lines=node=>{const range=document.createRange();range.selectNodeContents(node);return new Set([...range.getClientRects()].map(rect=>px(rect.top))).size;};
const heading=main.querySelector(production?':scope > .workspace-heading':':scope > .heading');
const copy=production?heading.querySelector('.workspace-heading-copy'):heading.firstElementChild;
const heading=main.querySelector(':scope > .heading');
const copy=heading.firstElementChild;
const h1=copy.querySelector('h1'),subtitle=copy.querySelector('p'),actions=heading.querySelector('.heading-actions');
const grid=main.querySelector(':scope > .two-col'),gridBox=grid.getBoundingClientRect();
const surfaces=[...grid.querySelectorAll(':scope > section.surface')];
+1 -1
View File
@@ -19,7 +19,7 @@ const categoryOperation=(page,name)=>main(page).locator('.operation').filter({ha
async function seed(request,data={}) {const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();}
async function open(page,theme,count) {
await page.goto('about:blank');await page.goto(route);
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
if(count!==undefined)await expect(rows(page)).toHaveCount(count);
}
async function openDialog(page,trigger) {await trigger.click();await expect(dialog(page)).toBeVisible();return dialog(page);}
+46 -6
View File
@@ -224,7 +224,7 @@ test('every ordered receipt remains selected through distinct current locations,
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
await expect(dialog(page)).toContainText('Context membership unknown');
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
@@ -245,7 +245,7 @@ test('inspection first discovers a real restore and immediately retires every su
const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection'));
try {
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await page.evaluate(()=>{
window.recordedRecoveryStates=[];
@@ -259,7 +259,7 @@ test('inspection first discovers a real restore and immediately retires every su
} finally {await command(request,{action:'release'});}
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
const viewer=page.locator('dialog#viewer-dialog');
if(await viewer.count())await page.keyboard.press('Escape');
await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);
await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0);
@@ -297,7 +297,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
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');
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
@@ -353,7 +353,7 @@ test('route reentry restores older timeline range and both scroll axes after 61
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`final fix review states ${width} ${theme}`,async({page,request},testInfo)=>{
test.setTimeout(60000);await page.setViewportSize({width,height});await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await missing.getByRole('button',{name:'Select reported output',exact:true}).click();
await expect(missing).toHaveAttribute('aria-current','true');await expect(missing.getByRole('button',{name:'Retry current availability',exact:true})).toBeVisible();
@@ -458,9 +458,49 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
});
test('the inline current file names its prompts apart from the frozen run fields',async({page})=>{
// The current file resolves its own prompts, as a real host's saved PNG does, so the dialog holds two prompt texts per role.
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
await route.fulfill({response,json:body});});
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'));
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();
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);
});
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
await route.fulfill({response,json:body});});
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();
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).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
// Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
const snapshot=await dialog(page).ariaSnapshot();const counts=new Map();
for(const line of snapshot.split('\n')){const match=/^\s*- (button|region|link) "((?:[^"\\]|\\.)*)"/.exec(line);if(!match)continue;
const key=`${match[1]} ${match[2]}`;counts.set(key,(counts.get(key)||0)+1);}
expect(counts.size).toBeGreaterThan(10);
const receiptNames=await dialog(page).locator('[data-receipt]').first().evaluate(row=>[...row.querySelectorAll('button,a,[role="region"]')].map(node=>node.getAttribute('aria-label')||node.textContent.trim()));
const repeated=[...counts].filter(([key,n])=>n>1&&!receiptNames.some(name=>key.endsWith(` ${name}`)));
expect(repeated).toEqual([]);
});
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
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).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
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.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,7 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
async function mountEditor(page,theme) {
await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
if(theme)await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.evaluate(async()=>{
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
window.editorEvents={committed:[],reconciled:[]};
@@ -433,7 +433,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
// Real entry actions: Library, Images and prompt history headings, and the Negative variants panel.
const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions');
const headingActions=page=>page.locator('#workspace-main > .heading > .heading-actions');
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const toast=page=>page.locator('#workspace-toast');
@@ -478,8 +478,9 @@ test('library heading offers Export then + New prompt, and New opens the saved p
test('new from Images cancel preserves browse',async({page})=>{
await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images');
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']);
const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
// #249 C2: the concept's Images heading puts Import before New prompt.
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['Import','New prompt']);
const trigger=actions.last();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0);
@@ -679,6 +680,9 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
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 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();
@@ -866,3 +870,25 @@ test('long and error states retain footer focus and reflow',async({page})=>{
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
});
// 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'];
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)) {
const [hash,openMode]=conceptModes[mode];
await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);await expect(page.locator('#modal')).toBeVisible();
if(mode==='revision')await page.locator('#modal-close').focus();
references[mode]=mapConcept(await page.locator('#modal').evaluate(inventoryOf));
}
await mountEditor(page,theme);
expect(await page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')))).toEqual(CONCEPT_BASE);
const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}};
for(const mode of Object.keys(conceptModes)) {
const dialog=await openEditor(page,options[mode]);await ready(dialog);
expect(diffInventory(references[mode],await dialog.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
}
});
+9 -9
View File
@@ -129,7 +129,7 @@ test('returning from history restores library context and mobile content stays i
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 page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
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);
});
@@ -140,9 +140,9 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
const position=await page.evaluate(()=>scrollY);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click());
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',exact:true}).evaluate(node=>node.click());
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(cards(page)).toHaveCount(0);
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
@@ -188,7 +188,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
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();
await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
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});
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
@@ -197,13 +197,13 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();
await media(page).first().getByRole('button',{name:'Open same.png',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();
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).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();}
});
@@ -279,10 +279,10 @@ test('Library natural rows retain long prompt content without overflow',async({p
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-heading-copy > .intro')).toContainText('One positive.');
await expect(page.locator('.library-shelf > .intro')).toHaveCount(0);
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:'Appearance',exact:true}).selectOption(theme);
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('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
+92 -73
View File
@@ -8,14 +8,14 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
const primary = ['images', 'library', 'tags', 'inspect'];
const tools = ['overview', 'system'];
const tools = ['system', 'overview'];
async function noOverflow(page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
async function groupedNavigation(page) {
for (const [group, routes] of [['primary', primary], ['tools', tools]]) {
const region = page.locator(`.workspace-nav-${group}`);
for (const [group, routes] of [['primary', primary], ['system', tools]]) {
const region = page.locator(`#${group}-nav`);
await expect(region).toHaveCount(1);
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
}
@@ -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', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-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', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
const node = document.querySelector(selector);
if (!node) return [selector, null];
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
@@ -92,7 +92,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
const reset = await request.post('/__test__/reset', { data: { chronological_history: true } });await expect(reset).toBeOK();const fixture = await reset.json();
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
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 });
@@ -104,12 +104,12 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
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();
// 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-heading button.send')).toBeEnabled();
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-open').first().click();
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('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape');
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']]) {
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
}
@@ -122,7 +122,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible();
await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog');
const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click();
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
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();
@@ -131,7 +131,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
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();
await compareCanonicalHistory(reference, actual, testInfo);
@@ -197,26 +197,26 @@ test('shell matches approved stable geometry in both themes', async ({ page, req
for (const route of ['images', 'library']) {
await page.goto(`${workspace}#/${route}`);
if (width === 1600) await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
await expect(page.locator('.workspace-heading > .workspace-heading-copy > h1')).toBeVisible();
await expect(page.locator('#workspace-main > .heading > div > h1#page-title')).toBeVisible();
await groupedNavigation(page);
await expect(page.locator(route === 'images' ? 'article[data-image-key]' : 'article[data-prompt-id]').first()).toBeVisible();
await expect(page.locator('.brand')).toHaveAttribute('href', '#/images');
await expect(page.locator('#workspace-main > .eyebrow')).toHaveCount(0);
for (const [theme, active] of [['dark', 'rgb(20, 54, 77)'], ['light', 'rgb(228, 241, 251)']]) {
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await expect(page.locator('nav [aria-current="page"]')).toHaveCSS('background-color', active);
await expect(page.locator('h1')).toHaveCSS('font-weight', '620');
await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px');
await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px');
await expect(page.locator('#workspace-main > .heading')).toHaveCSS('margin-block-end', '22px');
await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px');
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCSS('line-height', '18px');
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveCSS('line-height', '18px');
await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px');
if (width === 1600) {
await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
await expect(page.locator('.topbar')).toHaveCSS('height', '68px');
} else {
await expect(page.locator('.sidebar')).toHaveCSS('position', 'static');
expect(await page.locator('.workspace-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
expect(await page.locator('.app-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
}
await noOverflow(page);
await attachEvidence(page, testInfo, `${route}-${width}-${theme}`);
@@ -236,8 +236,8 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
const link = page.locator(`nav [data-route="${route}"]`);
await link.focus(); await expect(link).toBeFocused();
}
await page.getByRole('combobox', { name: 'Appearance', exact: true }).focus();
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeFocused();
await page.getByRole('combobox', { name: 'Theme', exact: true }).focus();
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeFocused();
const text = page.locator('.prompt-text').filter({ hasText: 'A study in very long creative context' });
await text.scrollIntoViewIfNeeded();
expect((await text.textContent()).length).toBeGreaterThan(1500);
@@ -257,17 +257,17 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
}
});
test('Appearance label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
test('Theme label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
const preference = page.getByRole('combobox', { name: 'Appearance', exact: true });
const preference = page.getByRole('combobox', { name: 'Theme', exact: true });
const bounds = await preference.evaluate(select => {
const field = select.closest('label');
return { intendedWrapper: field.classList.contains('workspace-appearance'),
boxes: [field, field.querySelector('.ca-field-label'), select].map(node => {
return { intendedWrapper: field.classList.contains('theme-label') && field.parentElement.classList.contains('top-actions'),
boxes: [field, select].map(node => {
const box = node.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width };
}), width: document.documentElement.clientWidth };
});
@@ -277,7 +277,7 @@ test('Appearance label and control fit the narrow shell at 200 percent zoom', as
expect(box.right).toBeLessThanOrEqual(bounds.width + 1);
}
expect(bounds.intendedWrapper).toBe(true);
for (const theme of ['follow', 'light', 'dark']) {
for (const theme of ['system', 'light', 'dark']) {
await preference.selectOption(theme); await expect(preference).toHaveValue(theme);
}
await preference.focus(); await expect(preference).toBeFocused();
@@ -290,18 +290,18 @@ async function containedImageContent(page) {
const failures = [];
for (const card of nodes) {
const outer = card.getBoundingClientRect();
for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) {
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
const node = card.querySelector(selector), box = node.getBoundingClientRect();
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
(selector === 'small' && node.scrollHeight > node.clientHeight + 1)) {
(selector === '.media-info p' && node.scrollHeight > node.clientHeight + 1)) {
failures.push({ key: card.dataset.imageKey, selector,
card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom },
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
}
}
const action = card.querySelector('.image-card-footer button').getBoundingClientRect();
const range = document.createRange(); range.selectNodeContents(card.querySelector('small'));
const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
for (const text of range.getClientRects()) {
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) {
@@ -341,18 +341,30 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused();
expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey);
}
const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' });
const filename = await longCard.locator('strong').textContent();
const longCard = cards.filter({ has: page.locator('[aria-label^="Open a-very-long-geometric-composition-"]') });
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
expect(filename).toMatch(/layered-context-.*\.png/);
await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter');
// 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 fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();
await expect(page.locator('.image-preview > header h2')).toHaveText(filename);
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await expect(page.locator('#viewer-title')).toHaveText(filename);
await page.keyboard.press('Escape');
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused();
// 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 });
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');
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');
await expect.poll(() => containedImageContent(page)).toEqual([]);
@@ -374,7 +386,7 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26);
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(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
await more.click(); await expect(page.locator('.section-caption')).toContainText(`${loaded} of 202 images`);
}
await expect.poll(() => cards.count()).toBeLessThan(120);
await page.evaluate(stride => {
@@ -401,33 +413,32 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
await selected.locator('.image-open').click(); await selected.locator('.image-open').focus();
const filename = await selected.locator('strong').textContent();
await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
await page.evaluate(() => {
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')];
const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get;
const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]);
const restore = () => { for (const [node, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(node, 'offsetHeight', descriptor); else delete node.offsetHeight;
} };
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.media-info')];
// Production measures each footer's fractional height (`getBoundingClientRect().height / scale`, #249 C7), so the
// probe records the visible anchor at the first measurement after the zoom, before the grid relays out.
const nativeRect = Element.prototype.getBoundingClientRect;
const restore = () => { for (const node of footers) delete node.getBoundingClientRect; };
window.footerMeasurementProbe = { before: null, restore };
for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() {
const height = nativeHeight.call(this);
for (const footer of footers) Object.defineProperty(footer, 'getBoundingClientRect', { configurable: true, writable: true, value() {
const rect = nativeRect.call(this);
if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' &&
Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
Math.abs(Number.parseFloat(this.closest('.media-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey,
top: first.getBoundingClientRect().top - top } : null;
restore();
}
return height;
return rect;
} });
document.documentElement.style.zoom = '4';
const box = grid.getBoundingClientRect();
// The approved initial reservation is 54px with 4:3 media and a 13px row gap.
const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13;
// The approved initial reservation is 54px under the concept's 1.33 media and a 13px row gap.
const stride = (box.width / 4 - 2) / 1.33 + 54 + 2 + 13;
// Place row eight inside the 52px scaled gap so the preceding row is not visible.
const index = 8;
scrollTo(0, box.top + scrollY + index * stride * 4 - 16);
@@ -454,10 +465,10 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
await page.getByRole('button', { name: 'List', exact: true }).click();
await expect.poll(() => containedImageContent(page)).toEqual([]);
expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match');
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('no-real-media-match');
await expect(cards).toHaveCount(0);
expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('');
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('');
await expect(cards).toHaveCount(24);
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')));
@@ -479,7 +490,7 @@ test('supplemental wrapped real footer reservation survives append without movin
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(40);
await cards.first().locator('.image-open').click();
await cards.first().locator('.image-card-actions button').click();
const selectedKey = await cards.first().getAttribute('data-image-key');
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
@@ -491,18 +502,23 @@ test('supplemental wrapped real footer reservation survives append without movin
});
try {
const more = page.getByRole('button', { name: 'Load more images', exact: true });
await more.click(); await expect.poll(() => pending).toBe(true);
// At 4x zoom the concept continuation (copy + Load more) fills the viewport, so keep the last cards in view and
// press Load more from the keyboard without scrolling to it.
await cards.last().scrollIntoViewIfNeeded();
await more.evaluate(node => node.focus({ preventScroll: true })); await page.keyboard.press('Enter');
await expect.poll(() => pending).toBe(true);
const anchor = await cards.evaluateAll(nodes => {
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
const node = nodes.find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null;
});
expect(anchor).not.toBeNull();
release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images');
release(); await expect(page.locator('.section-caption')).toContainText('80 of 202 images');
await expect(cards).toHaveCount(80);
await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1);
await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/);
await expect(more).toBeFocused();
// The concept focuses the first appended image after a Load more button press.
await expect(page.locator('article[aria-posinset="41"] .image-open')).toBeFocused();
await expect.poll(() => containedImageContent(page)).toEqual([]);
} finally { release(); }
});
@@ -514,15 +530,15 @@ test('Library visual evidence keeps natural cards and returns from real history
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));await startCoverage(page,browserName);
try {
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
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 expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('.library-history > div > .intro')).toHaveCount(1);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. 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-heading-copy > .intro')).toHaveCount(1);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
expect(errors).toEqual([]);
} finally {await saveCoverage(page,browserName);await context.close();}
}
@@ -533,11 +549,11 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
await page.goto(`${workspace}#/images`);
const icons = await page.evaluate(async () => {
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch'];
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
const otherDocument = document.implementation.createHTMLDocument();
const rendered = names.map(name => {
const icon = createWorkspaceIcon(name, otherDocument);
return { name, svg: icon instanceof SVGSVGElement, owner: icon.ownerDocument === otherDocument,
return { name, svg: icon instanceof SVGSVGElement, className: icon.getAttribute('class'), owner: icon.ownerDocument === otherDocument,
viewBox: icon.getAttribute('viewBox'), hidden: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable'),
fill: icon.getAttribute('fill'), stroke: icon.getAttribute('stroke'), width: icon.getAttribute('stroke-width'),
path: icon.querySelector('path').getAttribute('d'), children: icon.childElementCount };
@@ -549,10 +565,12 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
});
expect(icons.unknown).toEqual([true, true, true, true, true]);
for (const icon of icons.rendered) {
expect(icon).toMatchObject({ svg: true, owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
expect(icon.path.length).toBeGreaterThan(10);
}
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 12h4l3-8 4 16 3-8h4');
// D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4').
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6');
expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length);
});
@@ -575,33 +593,34 @@ test('Images visual layouts retain real delayed media long filenames and loaded
await page.setViewportSize({width:1600,height:1100});await page.goto(`${workspace}#/images`,{waitUntil:'domcontentloaded'});
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
await expect.poll(()=>mediaRequests).toBeGreaterThan(0);
expect(await page.locator('.image-card img').first().evaluate(image=>image.complete)).toBe(false);
await expect(page.locator('.image-card').first()).toHaveCSS('border-radius','7px');
expect(await page.locator('.media-card img').first().evaluate(image=>image.complete)).toBe(false);
await expect(page.locator('.media-card').first()).toHaveCSS('border-radius','7px');
await orderedMedia(page);
} finally {release();}
for (const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});
for (const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await page.locator('.image-card .image-open').first().click();
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');
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('.image-card').first()).toHaveCSS('height','97px');
if(layout==='List') await expect(page.locator('.media-card').first()).toHaveCSS('height','97px');
await orderedMedia(page);
await attachEvidence(page,testInfo,`after-images-${layout.toLowerCase()}-${width}-${theme}`,false);
}
await page.locator('article[data-image-key]').last().scrollIntoViewIfNeeded();
const longCard=page.locator('article[data-image-key]').filter({hasText:'a-very-long-geometric-composition-'});
await expect(longCard.locator('strong')).toHaveText(/layered-context-.*\.png/);
await longCard.locator('.image-open').click();
await expect(page.locator('.image-details h2')).toHaveText(await longCard.locator('strong').textContent());
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
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 orderedMedia(page);
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
}
}
await page.evaluate(()=>{location.hash='#/overview';});
await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
await expect(page.locator('.media-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
});
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
@@ -618,10 +637,10 @@ test('embedded history gallery keeps the history heading and disposes its layout
for (const layout of ['List','Masonry','Grid']) {
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
await expect(page.locator('h1')).toHaveText(title);
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
}
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
await expect(page.locator('.media-card,.image-details,dialog[open]')).toHaveCount(0);
}
});
+41 -12
View File
@@ -43,8 +43,8 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
test('tags route renders the concept composition without a legacy handoff',async({page})=>{
await page.goto(route);
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
await expect(main(page).locator('.workspace-heading-copy > p')).toHaveText('Organize once. Reuse everywhere.');
const actions=main(page).locator('.workspace-heading .heading-actions button');
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
const actions=main(page).locator(':scope > .heading > .heading-actions button');
await expect(actions).toHaveText(['+ Create tag']);
await expect(actions.first()).not.toHaveClass(/ca-primary/);
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
@@ -89,7 +89,7 @@ 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});
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',exact:true}).click();
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');
await row.getByRole('button',{name:'Browse',exact:true}).click();
await expectLibrary(page,{category:'Landscapes',count:3});
@@ -127,7 +127,8 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
test('read failure shows inline error with Retry and no zero counts',async({page})=>{
let failures=0;
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
// Tag list pages only: the sidebar's Tags count is its own `limit=1` read.
await page.route(url=>url.pathname==='/prompt_manager/vocabulary/tags' && url.searchParams.get('limit')!=='1',route=>{
if(failures++) return route.continue();
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
});
@@ -149,7 +150,7 @@ test('breadcrumb and document title are Tags',async({page})=>{
await page.goto(route);
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
await expect(page).toHaveTitle('Tags · Prompt Manager');
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true})).toHaveAttribute('aria-current','page');
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/})).toHaveAttribute('aria-current','page');
await page.reload();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
await page.goto('/prompt_manager/workspace#/library');
@@ -161,7 +162,8 @@ test('route disposal aborts requests and removes observers',async({page})=>{
await page.addInitScript(()=>{
window.__vocabularySignals=[];window.__vocabularyObservers=[];
const fetch=window.fetch;
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
// The page's own reads; the sidebar's `limit=1` Tags count belongs to the shell.
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && !/[?&]limit=1(?:&|$)/.test(String(input)) && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
const Observer=window.IntersectionObserver;
window.IntersectionObserver=class extends Observer {
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
@@ -175,7 +177,7 @@ test('route disposal aborts requests and removes observers',async({page})=>{
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).click();
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
@@ -193,10 +195,10 @@ test('390px layout stacks without horizontal page scroll; table scrolls locally'
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
const layout=await main(page).evaluate(node=>{
const grid=node.querySelector('.two-col'),[left,right]=grid.querySelectorAll(':scope > .surface'),wrap=node.querySelector('.table-wrap');
const heading=node.querySelector('.workspace-heading'),operation=node.querySelector('.operation');
const heading=node.querySelector(':scope > .heading'),operation=node.querySelector('.operation');
return {tracks:getComputedStyle(grid).gridTemplateColumns.split(' ').length,stacked:right.getBoundingClientRect().top>=left.getBoundingClientRect().bottom,
overflow:getComputedStyle(wrap).overflowX,heading:getComputedStyle(heading).flexDirection,operation:getComputedStyle(operation).flexDirection,
actions:node.querySelector('.workspace-heading .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
actions:node.querySelector(':scope > .heading > .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
});
expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true});
});
@@ -482,7 +484,8 @@ test('lost response is outcome-unknown',async({page,request})=>{
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
});
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
// 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}$`)});
/** 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) {
@@ -666,10 +669,10 @@ async function openConceptTags(page,theme,motion) {
async function openProductionTags(page,theme,motion) {
await page.emulateMedia({reducedMotion:motion});
await page.goto('about:blank');await page.goto(route);
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
}
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf,production);
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
async function chromeOf(page,production) {
const scope=page.locator(production?'#workspace-main':'#main');
@@ -791,3 +794,29 @@ test('mutation check: the comparison reports a changed surface padding and a cha
expect(dialogPaths).toContain('fields.0.control.color');
await page.keyboard.press('Escape');
});
// 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'];
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});
const donors=await conceptDonors(page,theme);
// Park the pointer: donor clicks leave it over a heading or row button, and a hover background is not compared.
await openConceptTags(page,theme,'no-preference');await page.mouse.move(0,0);const reference=await pageOf(page,false);
const dialogs={};
for(const mode of Object.keys(conceptDialogs)) {
await openConceptTags(page,theme,'no-preference');await conceptDialogs[mode](page);
await expect(page.locator('#modal')).toBeVisible();dialogs[mode]=await page.locator('#modal').evaluate(inventoryOf);
}
await openProductionTags(page,theme,'no-preference');await page.mouse.move(0,0);
expect(await loadedSheets(page)).toEqual(CONCEPT_BASE);
const {diff,extra}=comparePage(reference,await pageOf(page,true));
expect(diff).toEqual([]);expect(extra).toEqual(fixtureOnly());
for(const mode of Object.keys(productionDialogs)) {
const box=await productionDialogs[mode](page);
expect(diffInventory(mapVocabularyDialog(mode,dialogs[mode],donors),await box.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
}
});
+521 -42
View File
@@ -4,21 +4,27 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
const workspace = '/prompt_manager/workspace';
const statistic = (page, label) => page.locator('#workspace-main dt')
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
// Concept sidebar: WORKSPACE links carry a real `.nav-count` once loaded, so their accessible name may end in it.
const escapeName = label => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const navName = label => new RegExp(`^${escapeName(label)}(?:\\s*[\\d,]+)?$`);
const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: navName(label) });
const sidebarLinks = page => page.locator('aside.sidebar nav a');
const routes = [
['overview', 'Overview', null],
['images', 'Images', '/prompt_manager/gallery'],
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
['images', 'Images', null],
['library', 'Prompt library', '/prompt_manager/admin'],
['tags', 'Tags & categories', null],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
];
async function countAppearanceWrites(page) {
async function countThemeWrites(page) {
await page.evaluate(() => {
window.__appearanceChanges = 0;
window.__themeChanges = 0;
const original = Storage.prototype.setItem;
Storage.prototype.setItem = function (...args) {
if (args[0] === 'pm_workspace_theme') window.__appearanceChanges++;
if (args[0] === 'pm_workspace_theme') window.__themeChanges++;
return original.apply(this, args);
};
});
@@ -66,8 +72,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
await page.goto(`${workspace}#/${route}`);
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
if (legacy) {
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
@@ -76,16 +81,23 @@ 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.
await page.goto(`${workspace}#/overview`);
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
await gallery.click();
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
const images = navigation.getByRole('link', { name: 'Images', exact: true });
const images = navigation.getByRole('link', { name: navName('Images') });
await images.focus();
await page.keyboard.press('Enter');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
await navigation.getByRole('link', { name: navName('Prompt library') }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goBack();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
@@ -93,7 +105,7 @@ test('keyboard navigation, browser history and unknown routes remain usable', as
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goto(`${workspace}#/not-a-page`);
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
await expect(navigation.getByRole('link', { name: navName('Images') }))
.toHaveAttribute('aria-current', 'page');
});
@@ -101,44 +113,144 @@ test('skip to content focuses the current view without changing its route', asyn
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
await expect(page.getByRole('link', { name: 'Skip to workspace', exact: true })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(`${workspace}#/library`);
await expect(page.locator('#workspace-main')).toBeFocused();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
});
// The concept focuses its page title on every hashchange navigation (docs/concepts/workspace/app.js 1168), so a screen
// reader announces "<page>, heading level 1"; the first render leaves focus alone.
test('a sidebar route change focuses the page title, as the concept does', async ({ page }) => {
await page.goto(`${workspace}#/library`);
const title = page.locator('#page-title');
await expect(title).toHaveText('Prompt library');
await expect(title).not.toBeFocused();
await sidebarLink(page, 'Images').click();
await expect(title).toHaveText('Images');
await expect(title).toBeFocused();
await expect(page.locator('#workspace-main')).not.toBeFocused();
await sidebarLink(page, 'Tags & categories').click();
await expect(title).toHaveText('Tags & categories');
await expect(title).toBeFocused();
await page.goBack();
await expect(title).toHaveText('Images');
await expect(title).toBeFocused();
});
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
await page.goto(`${workspace}#/overview`);
const prompts = statistic(page, 'Prompts');
await expect(prompts).toHaveText('2');
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await sidebarLink(page, 'Images').click();
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
await sidebarLink(page, 'Overview').click();
await expect(prompts).toHaveText('3');
await expect(navigation).toHaveCount(1);
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
await expect(sidebarLinks(page)).toHaveCount(routes.length);
});
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
test('theme System follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
await page.goto(`${workspace}#/overview`);
const theme = page.locator('[data-ca-theme]').first();
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
await appearance.selectOption({ label: 'Dark' });
const themeSelect = page.getByRole('combobox', { name: 'Theme', exact: true });
await expect(themeSelect).toHaveValue('system');
await themeSelect.selectOption({ label: 'Dark' });
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
await page.reload();
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
await expect(appearance).toHaveValue('dark');
await appearance.selectOption({ label: 'Light' });
await expect(themeSelect).toHaveValue('dark');
await themeSelect.selectOption({ label: 'Light' });
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
await appearance.selectOption({ label: 'Follow ComfyUI' });
await themeSelect.selectOption({ label: 'System' });
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
});
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
test('topbar matches the concept actions', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
const topbar = page.locator('.workspace > header.topbar');
await expect(topbar).toHaveCount(1);
const shape = await topbar.evaluate(node => ({
children: [...node.children].map(child => `${child.localName}.${child.className}`),
crumbs: [...node.querySelector('.breadcrumbs').childNodes].filter(item => item.nodeType === 1 || item.textContent.trim())
.map(item => item.nodeType === 1 ? `${item.localName}${item.id ? `#${item.id}` : ''}:${item.textContent}` : item.textContent.trim()),
actions: [...node.querySelector('.top-actions').children].map(child => `${child.localName}.${child.className}`),
label: node.querySelector('.theme-label').firstChild.textContent.trim(),
labelFor: node.querySelector('.theme-label').htmlFor,
options: [...node.querySelectorAll('#theme option')].map(option => [option.value, option.textContent]),
arrow: [...node.querySelectorAll('.canvas-link span')].map(span => [span.textContent, span.getAttribute('aria-hidden')]),
tokens: {
height: getComputedStyle(node).height, padding: getComputedStyle(node).padding,
crumbs: getComputedStyle(node.querySelector('.breadcrumbs')).fontSize,
current: getComputedStyle(node.querySelector('#workspace-breadcrumb')).color === getComputedStyle(document.body).color,
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
select: getComputedStyle(node.querySelector('#theme')).fontSize,
link: getComputedStyle(node.querySelector('.canvas-link')).fontSize,
gap: getComputedStyle(node.querySelector('.top-actions')).columnGap,
},
}));
expect(shape).toEqual({
children: ['div.breadcrumbs', 'div.top-actions'],
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
actions: ['label.theme-label', 'a.canvas-link'],
label: 'Theme', labelFor: 'theme',
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
arrow: [['↗', 'true']],
tokens: { height: '68px', padding: '0px 30px', crumbs: '12px', current: true, label: '11px', select: '11px', link: '12px', gap: '12px' },
});
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
await expect(theme).toHaveValue('system');
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
await expect(link).toHaveText('ComfyUI ↗');
// D6: the ComfyUI canvas root under the page's mount (resolveUrl), never a hard-coded origin.
await expect(link).toHaveAttribute('href', '/');
expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true);
await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0);
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
await page.locator('nav [data-route="library"]').click();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
});
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
// label text collapses while the select keeps its accessible name.
test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
await expect(theme).toBeVisible();
await expect(page.locator('.breadcrumbs')).toBeHidden();
const layout = await page.locator('.topbar').evaluate(node => {
const bar = node.getBoundingClientRect(), actions = node.querySelector('.top-actions');
const box = element => element.getBoundingClientRect();
return {
padding: getComputedStyle(node).paddingInline,
actions: { width: box(actions).width, justify: getComputedStyle(actions).justifyContent, gap: getComputedStyle(actions).columnGap },
expected: bar.width - 28,
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
select: getComputedStyle(node.querySelector('#theme')).fontSize,
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
};
});
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1);
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible();
await theme.selectOption('light');
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test('unavailable host palette falls back to the system theme and tracks system changes', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`${workspace}#/overview`);
@@ -170,15 +282,14 @@ test('overview reports loading, offers retry after failure and renders real empt
}
});
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
for (const [, label] of routes) {
await navigation.getByRole('link', { name: label, exact: true }).click();
await sidebarLink(page, label).click();
await expect(page.locator('#workspace-main h1')).toHaveText(label);
}
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
@@ -196,20 +307,19 @@ test('leaving a loading overview prevents its delayed result from replacing the
});
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
await sidebarLink(page, 'Images').click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
release();
await finished;
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(page.locator('#workspace-main')).not.toContainText('999');
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
await countAppearanceWrites(page);
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption('light');
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
await expect(page.locator('#primary-nav')).toHaveCount(1);
await expect(page.locator('#system-nav')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
await countThemeWrites(page);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light');
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
});
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
@@ -227,15 +337,384 @@ test('cached-page restoration remounts one working shell and refreshes its data'
await expect(statistic(page, 'Prompts')).toHaveText('2');
}
expect(reads).toBe(3);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
await countAppearanceWrites(page);
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await expect(sidebarLinks(page)).toHaveCount(routes.length);
await expect(page.locator('#primary-nav')).toHaveCount(1);
await expect(page.locator('#system-nav')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
await countThemeWrites(page);
await sidebarLink(page, 'Images').click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await page.getByRole('combobox', { name: 'Theme' }).selectOption('light');
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
});
// Task A2: the Overview and handoff panels use the concept's `.surface` (style.css 709-722) and `.small` from the
// concept base stylesheet, not the retired shell sheet (18px h2, 11px panel radius).
const surfaceStyle = node => {
const style = getComputedStyle(node);
const heading = getComputedStyle(node.querySelector(':scope > h2'));
const intro = node.querySelector(':scope > p');
return {
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
radius: style.borderTopLeftRadius, border: `${style.borderTopWidth} ${style.borderTopStyle}`,
background: style.backgroundColor,
h2: { fontSize: heading.fontSize, fontWeight: heading.fontWeight, marginTop: heading.marginTop, marginBottom: heading.marginBottom },
intro: intro && { fontSize: getComputedStyle(intro).fontSize, marginTop: getComputedStyle(intro).marginTop },
};
};
const SURFACE = {
padding: ['22px', '22px', '22px', '22px'], radius: '7px', border: '1px solid',
h2: { fontSize: '17px', fontWeight: '600', marginTop: '0px', marginBottom: '0px' },
intro: { fontSize: '12px', marginTop: '7px' },
};
for (const width of [1600, 390]) test(`overview and handoff render with concept surfaces ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto(`${workspace}#/overview`);
const main = page.locator('#workspace-main');
await expect(statistic(page, 'Prompts')).toHaveText('2');
const tools = main.locator('section.surface').filter({ has: page.getByRole('heading', { level: 2, name: 'Continue creating', exact: true }) });
await expect(tools).toHaveCount(1);
const surface = await tools.evaluate(surfaceStyle);
expect(surface).toMatchObject(SURFACE);
expect(surface.background).not.toBe('rgba(0, 0, 0, 0)');
await expect(main.locator('.panel')).toHaveCount(0);
const card = tools.locator('.tool-card').first();
await expect(card.locator('p.small')).toHaveCount(1);
expect(await card.locator('p.small').evaluate(node => getComputedStyle(node).fontSize)).toBe('12px');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await sidebarLink(page, 'System').click();
const handoff = main.locator('section.handoff');
await expect(handoff).toHaveClass(/\bsurface\b/);
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
await expect(handoff.locator('a[href="/prompt_manager/admin"]')).toBeVisible();
await expect(main.locator('.panel')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
// Task A3: the concept sidebar (index.html h16-41, app.js nav()) rendered from real data (D27, D28).
const NAV_TOTALS = [
['images', 'Images', '/prompt_manager/images/output?mode=workspace&limit=1'],
['library', 'Prompt library', '/prompt_manager/library?limit=1'],
['tags', 'Tags & categories', '/prompt_manager/vocabulary/tags?limit=1'],
];
async function serverTotals(request) {
const totals = {};
for (const [id, , url] of NAV_TOTALS) {
const response = await request.get(url);
await expect(response).toBeOK();
totals[id] = (await response.json()).total;
expect(Number.isSafeInteger(totals[id]) && totals[id] >= 0).toBe(true);
}
return totals;
}
const navCount = (page, route) => page.locator(`#primary-nav a[data-route="${route}"] .nav-count`);
const childShape = locator => locator.evaluate(node => [...node.children]
.map(child => `${child.localName}${child.id ? `#${child.id}` : ''}${[...child.classList].map(name => `.${name}`).join('')}`));
test('sidebar matches the concept groups and shows real counts', async ({ page, request }) => {
const totals = await serverTotals(request);
expect(totals.library).toBe(2);
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
await expect(page.locator('body > .app-shell > aside.sidebar')).toHaveCount(1);
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
const sidebar = page.locator('aside.sidebar');
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
const primary = page.locator('nav#primary-nav');
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
const links = await primary.evaluate(node => [...node.children].map(link => ({ tag: link.localName,
href: link.getAttribute('href'), icon: link.querySelector('svg')?.getAttribute('class'),
nodes: [...link.childNodes].map(child => child.nodeType === Node.TEXT_NODE ? `text:${child.textContent}`
: `${child.localName}${[...child.classList].map(name => `.${name}`).join('')}`) })));
expect(links).toEqual([
{ tag: 'a', href: '#/images', nodes: ['svg.icon', 'text:Images', 'span.nav-count'], icon: 'icon' },
{ tag: 'a', href: '#/library', nodes: ['svg.icon', 'text:Prompt library', 'span.nav-count'], icon: 'icon' },
{ tag: 'a', href: '#/tags', nodes: ['svg.icon', 'text:Tags & categories', 'span.nav-count'], icon: 'icon' },
{ tag: 'a', href: '#/inspect', nodes: ['svg.icon', 'text:Inspect a file'], icon: 'icon' },
]);
await expect(primary.locator('a[href="#/images"]')).toHaveAttribute('aria-current', 'page');
await expect(primary.locator('a[aria-current]')).toHaveCount(1);
await expect(page.locator('#primary-nav a[data-route="inspect"] .nav-count')).toHaveCount(0);
// Concept tokens: 38px rows, 12px muted labels, 10px muted counts pushed to the end.
const style = await primary.locator('a[href="#/library"]').evaluate(link => {
const own = getComputedStyle(link), count = link.querySelector('.nav-count');
return { minHeight: own.minHeight, fontSize: own.fontSize, gap: own.columnGap, count: getComputedStyle(count).fontSize,
end: Math.round(link.getBoundingClientRect().right - count.getBoundingClientRect().right) };
});
expect(style).toEqual({ minHeight: '38px', fontSize: '12px', gap: '11px', count: '10px', end: 12 });
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
await page.setViewportSize({ width: 390, height: 844 });
await expect(navCount(page, 'library')).toBeHidden();
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
});
test('nav counts stay hidden until loaded and when their request fails', async ({ page, request }) => {
const totals = await serverTotals(request);
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/library?limit=1', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.route('**/prompt_manager/vocabulary/tags?limit=1', route => route.fulfill({ json: { success: true, total: -4 } }));
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// Nothing renders for a read that is outstanding: no "0" placeholder. The settled Images count does not wait for it.
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
await expect(navCount(page, 'library')).toHaveCount(0);
await expect(navCount(page, 'tags')).toHaveCount(0);
release();
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
await expect(navCount(page, 'library')).toHaveCount(0);
await expect(navCount(page, 'tags')).toHaveCount(0);
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(1);
});
// A3 review: the Images listing scans output folders, so its slow count must not hold back Library and Tags.
test('a slow Images count does not delay the Library and Tags badges', async ({ page, request }) => {
const totals = await serverTotals(request);
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') === '1', async route => {
await pending;
await route.continue().catch(() => {});
});
await page.goto(`${workspace}#/overview`);
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
await expect(navCount(page, 'images')).toHaveCount(0);
release();
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
});
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
await page.goto(`${workspace}#/system`);
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
const system = page.locator('nav#system-nav');
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
['#/system', 'System'], ['#/overview', 'Overview'],
]);
await expect(system.locator('.nav-count')).toHaveCount(0);
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
await expect(system.locator('svg.icon')).toHaveCount(2);
const connection = bottom.locator('.connection');
expect(await childShape(connection)).toEqual(['i', 'div']);
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
await expect(connection.locator('small')).toHaveText('Your library, on your server');
// D28: no fake connection state. The dot is the muted text colour, not the concept's demo green.
const dot = await connection.evaluate(node => ({ dot: getComputedStyle(node.querySelector('i')).backgroundColor,
muted: getComputedStyle(node.querySelector('small')).color }));
expect(dot.dot).toBe(dot.muted);
await page.locator('nav#system-nav').getByRole('link', { name: 'Overview', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(system.locator('a[href="#/overview"]')).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar a[aria-current]')).toHaveCount(1);
});
test('counts refresh after a confirmed editor save', async ({ page, request }) => {
const before = await serverTotals(request);
await page.goto(`${workspace}#/library`);
await expect(navCount(page, 'library')).toHaveText(String(before.library));
await expect(navCount(page, 'tags')).toHaveText(String(before.tags));
const reads = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.searchParams.get('limit') === '1') reads.push(url.pathname);
});
await page.getByRole('button', { name: '+ New prompt', exact: true }).click();
const dialog = page.locator('dialog.library-editor');
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toBeEnabled();
await dialog.getByLabel('Title', { exact: true }).fill('Harbor at dawn');
await dialog.getByLabel('Positive prompt', { exact: true }).fill('A quiet harbor at dawn for the sidebar count');
await dialog.getByLabel('Tags, separated by commas', { exact: true }).fill('sidebar_count_tag');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toHaveCount(0);
const after = await serverTotals(request);
expect(after.library).toBe(before.library + 1);
expect(after.tags).toBeGreaterThan(before.tags);
await expect(navCount(page, 'library')).toHaveText(String(after.library));
await expect(navCount(page, 'tags')).toHaveText(String(after.tags));
expect(reads.filter(path => path === '/prompt_manager/library').length).toBeGreaterThanOrEqual(1);
// A Library write cannot change the Images count, so the Images listing is not read again.
expect(reads.filter(path => path === '/prompt_manager/images/output')).toEqual([]);
});
test('a superseded count read never lands and a failed refresh removes the stale count', async ({ page, request }) => {
const totals = await serverTotals(request);
await page.goto(`${workspace}#/overview`);
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
const held = [];
let reads = 0;
await page.route('**/prompt_manager/library?limit=1', route => {
if (reads++ === 0) { held.push(route); return undefined; }
return route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
const invalidate = () => page.evaluate(async () => {
const { invalidateLibraryAfterWrite } = await import('/prompt_manager/js/workspace/library.mjs');
invalidateLibraryAfterWrite({ kind: 'test', receipt: {} });
});
await invalidate();
await expect.poll(() => held.length).toBe(1);
await invalidate();
await expect(navCount(page, 'library')).toHaveCount(0);
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
await held[0].fulfill({ json: { success: true, total: 999 } }).catch(() => {});
await page.waitForTimeout(100);
await expect(navCount(page, 'library')).toHaveCount(0);
await page.unroute('**/prompt_manager/library?limit=1');
// Leaving the page with a read in flight aborts it; the restored shell reads the counts again.
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/library?limit=1', async route => { await pending; await route.continue().catch(() => {}); }, { times: 1 });
await invalidate();
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
});
release();
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
await expect(page.locator('#primary-nav a')).toHaveCount(4);
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
});
// Task A5: the concept heading() (app.js 303-305) with its typography (style.css 73-103, 283-300; <=700 1408-1416) on
// every native route, and the production footer copy in the concept `.app-footer` (D29).
const headingShape = node => {
const style = element => getComputedStyle(element);
const shape = element => `${element.localName}${element.id ? `#${element.id}` : ''}${[...element.classList].map(name => `.${name}`).join('')}`;
const [copy, actions] = node.children;
const [h1, description] = copy.children;
const main = node.parentElement.getBoundingClientRect();
return {
children: [...node.children].map(shape), copy: [...copy.children].map(shape), tabIndex: h1.tabIndex,
direction: style(node).flexDirection, marginBottom: style(node).marginBottom,
h1: { fontSize: style(h1).fontSize, lineHeight: Math.round(parseFloat(style(h1).lineHeight) / parseFloat(style(h1).fontSize) * 100) / 100, fontWeight: style(h1).fontWeight, letterSpacing: style(h1).letterSpacing, marginTop: style(h1).marginTop },
description: { text: description.textContent.length > 0, fontSize: style(description).fontSize, marginTop: style(description).marginTop, muted: style(description).color === style(document.querySelector('.connection small')).color },
actions: { gap: style(actions).columnGap, left: Math.round(actions.getBoundingClientRect().left), fullWidth: Math.round(actions.getBoundingClientRect().width) === Math.round(node.getBoundingClientRect().width) },
mainLeft: Math.round(main.left + parseFloat(getComputedStyle(node.parentElement).paddingLeft)),
pageTitles: document.querySelectorAll('#page-title').length,
retired: document.querySelectorAll('.workspace-heading, .workspace-heading-copy, #workspace-main .intro').length,
};
};
test('every route renders the concept heading', async ({ page, request }) => {
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
const positiveId = library.items[0].id;
const pages = [['overview', 'Overview'], ['images', 'Images'], ['library', 'Prompt library'], ['tags', 'Tags & categories'], [`library/${positiveId}`, null]];
for (const width of [1600, 390]) {
await page.setViewportSize({ width, height: 900 });
for (const [hash, title] of pages) {
await page.goto(`${workspace}#/${hash}`);
const heading = page.locator('#workspace-main > .heading');
await expect(heading).toHaveCount(1);
if (title) await expect(heading.locator('h1')).toHaveText(title);
else await expect(heading.getByRole('button', { name: '← Prompt library', exact: true })).toBeVisible();
await expect(heading.locator('p.muted')).not.toHaveText('');
const shape = await heading.evaluate(headingShape);
expect(shape, hash).toMatchObject({
children: ['div', 'div.heading-actions'], copy: ['h1#page-title', 'p.muted'], tabIndex: -1, marginBottom: '22px',
h1: width === 1600 ? { fontSize: '28px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' }
: { fontSize: '25px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' },
description: { text: true, fontSize: '12px', marginTop: '6px', muted: true },
actions: { gap: '8px' }, pageTitles: 1, retired: 0,
});
if (width === 1600) {
expect(shape.direction, hash).toBe('row');
} else {
// The concept stacks the heading and gives the actions the full row, starting at main's 14px gutter.
expect(shape.direction, hash).toBe('column');
expect(shape.actions, hash).toMatchObject({ left: 14, fullWidth: true });
expect(shape.mainLeft, hash).toBe(14);
}
}
}
});
test('the footer is the concept app footer with production copy', async ({ page }) => {
for (const [width, padding, direction] of [[1600, '22px 30px', 'row'], [390, '20px 14px', 'column']]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(`${workspace}#/overview`);
const footer = page.locator('.workspace > footer.app-footer');
await expect(footer).toHaveCount(1);
await expect(page.locator('.page-footer')).toHaveCount(0);
expect(await footer.evaluate(node => ({
children: [...node.children].map(child => child.localName), text: node.textContent,
padding: getComputedStyle(node).padding, fontSize: getComputedStyle(node).fontSize, direction: getComputedStyle(node).flexDirection,
muted: getComputedStyle(node).color === getComputedStyle(document.querySelector('.connection small')).color,
afterMain: node.previousElementSibling.id,
}))).toEqual({ children: ['span'], text: 'ComfyAssets · Local tools. Your library.', padding, fontSize: '10px', direction, muted: true, afterMain: 'workspace-main' });
}
});
// #164: the maintainer serves ComfyUI behind a proxy prefix. Everything, the ComfyUI link included, stays on that mount.
const PROXY_MOUNT = '/ComfyBackendDirect';
async function serveUnderProxyMount(page) {
const escaped = [];
page.on('request', item => {
const { pathname } = new URL(item.url());
if (pathname.startsWith('/prompt_manager/')) escaped.push(pathname);
});
await page.route(url => url.pathname.startsWith(`${PROXY_MOUNT}/`), async route => {
const url = new URL(route.request().url());
url.pathname = url.pathname.slice(PROXY_MOUNT.length);
const response = await route.fetch({ url: url.href });
await route.fulfill({ response });
});
return escaped;
}
test('the ComfyUI link points to the root of a non-standard proxy mount', async ({ page }) => {
const escaped = await serveUnderProxyMount(page);
for (const route of ['overview', 'library', 'tags']) {
await page.goto(`${PROXY_MOUNT}${workspace}#/${route}`);
await expect(page.locator('#workspace-main > .heading h1')).toBeVisible();
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
await expect(link).toHaveAttribute('href', `${PROXY_MOUNT}/`);
expect(await link.evaluate(node => node.href)).toBe(new URL(`${PROXY_MOUNT}/`, page.url()).href);
}
await expect(navCount(page, 'library')).toHaveCount(1);
expect(escaped).toEqual([]);
});
test('a resolver failure for the ComfyUI link never stops the shell from mounting', async ({ page }) => {
await serveUnderProxyMount(page);
// Wrap the shared client as it loads so only the topbar's canvas-root lookup throws (the sender reads the same root).
// Every engine's stack names the calling module's URL; each injected failure is counted so a renamed module or a
// changed call site fails this test on the count instead of silently testing nothing.
await page.addInitScript(() => {
let client;
window.__resolverFailures = { injected: 0 };
Object.defineProperty(window, 'ApiClient', { configurable: true, get: () => client, set(value) {
client = { ...value, resolveUrl: (url, ...rest) => {
if (url === '/prompt_manager/' && /\/workspace\/topbar\.mjs\b/.test(String(new Error().stack))) {
window.__resolverFailures.injected += 1;
throw new Error('resolver unavailable');
}
return value.resolveUrl(url, ...rest);
} };
} });
});
const warnings = [];
page.on('console', message => { if (message.type() === 'warning') warnings.push(message.text()); });
await page.goto(`${PROXY_MOUNT}${workspace}#/overview`);
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(sidebarLinks(page)).toHaveCount(routes.length);
expect(await page.evaluate(() => window.__resolverFailures.injected), 'the topbar lookup was the one that failed').toBe(1);
expect(warnings.filter(text => text.includes('the ComfyUI link falls back to the page mount'))).toHaveLength(1);
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', `${PROXY_MOUNT}/`);
await sidebarLink(page, 'Prompt library').click();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
});
+468
View File
@@ -0,0 +1,468 @@
"""Concept-shaped real data for every concept comparison (D30 of #249).
The approved concept's six prompts (docs/concepts/workspace/app.js:71-178) and its
24 sample images (app.js:180-239) become real rows and real PNG files in this
server's disposable database and output folder, so production renders the
concept's shapes from its own stores: the same titles, texts, categories, tags,
ratings and negative names, the same image dimensions, filenames and seeds, and
the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
import os
from pathlib import Path
import shutil
import time
from types import SimpleNamespace
from typing import TYPE_CHECKING
from uuid import UUID, uuid5
from PIL import Image, ImageDraw
from PIL.PngImagePlugin import PngInfo
from utils.execution_contexts import (
ContextBundle,
PromptSnapshot,
RecipeContext,
TextEvidence,
)
from utils.execution_history_events import make_finish, make_start
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
from utils.pair_execution import EXECUTION_KEY
if TYPE_CHECKING:
from py.api import PromptManagerAPI
@dataclass(frozen=True)
class ConceptPrompt:
id: str
title: str
category: str
tags: tuple[str, ...]
positive: str
negatives: tuple[tuple[str, str], ...]
color: tuple[str, str, str]
rating: int
# app.js:71-178, in concept order. Negatives are (name, text) pairs.
CONCEPT_PROMPTS = (
ConceptPrompt(
"alpine",
"Alpine observatory",
"Landscapes",
("cinematic", "blue_hour", "architecture"),
"A remote alpine observatory at blue hour, warm light through glass, dramatic "
"mountain peaks, cinematic composition, atmospheric mist, fine detail",
(
("Quality baseline", "blurry, low quality, low resolution"),
(
"Clean architecture",
"blurry, low quality, low resolution, distorted architecture, "
"watermark, text",
),
("No negative", ""),
),
("#14233c", "#9aaac3", "#d8d9dd"),
5,
),
ConceptPrompt(
"dunes",
"Quiet geometry",
"Architecture",
("minimal", "warm_light", "editorial"),
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
"terracotta, late afternoon sun, architectural photography, quiet geometry",
(
("Natural materials", "plastic, harsh shadows, noise, watermark"),
(
"Clean lines",
"plastic, harsh shadows, noise, watermark, distorted geometry",
),
),
("#6c4948", "#ddad91", "#efe0c5"),
4,
),
ConceptPrompt(
"forest",
"Somewhere in the pines",
"Landscapes",
("forest", "mist", "peaceful"),
"A quiet cedar cabin above a still forest lake, towering evergreens, soft "
"morning mist, muted jade and moss, a small amber window, cinematic still",
(("Soft atmosphere", "oversaturated, blurry, low quality, text"),),
("#163b3d", "#6f9691", "#c0cfc1"),
5,
),
ConceptPrompt(
"city",
"After the rain",
"Environments",
("neon", "night", "cinematic"),
"Rain-soaked city street at night, blue neon and rose reflections, deep "
"perspective, cinematic science fiction, solitary architecture, detailed "
"atmosphere",
(
("Clean atmosphere", "blurry, low resolution, watermark, oversaturated"),
(
"Less signage",
"blurry, low resolution, watermark, oversaturated, text, signs",
),
),
("#1a2143", "#6869a1", "#cf8fbc"),
4,
),
ConceptPrompt(
"lunar",
"The long way home",
"Illustration",
("space", "minimal", "concept_art"),
"An isolated lunar research outpost, a luminous planet above the horizon, "
"sweeping pale terrain, quiet science fiction, finely detailed concept art",
(("Quality baseline", "blurry, low quality, watermark"),),
("#19283b", "#7398ac", "#e2d3c4"),
5,
),
ConceptPrompt(
"coast",
"Between sea and sky",
"Landscapes",
("coast", "soft_light", "peaceful"),
"A solitary lighthouse on a rugged coastal cliff, sea mist, glints of morning "
"light on turquoise water, atmospheric perspective, fine art landscape",
(("Natural light", "oversaturated, flat lighting, watermark, text"),),
("#255565", "#8cbcc2", "#e8d9bf"),
4,
),
)
# app.js:220-227: the image at flat index n has the n % 6 dimensions; with six
# prompts per round that is the prompt's own index.
CONCEPT_DIMENSIONS = (
(1536, 1152),
(1024, 1536),
(1280, 1280),
(1600, 900),
(1152, 1536),
(1536, 1024),
)
CONCEPT_IMAGE_ROUNDS = 4
CONCEPT_RUN_COUNT = 4
# The concept's Model fact (app.js:408).
CONCEPT_MODEL = "illustrative-model.safetensors"
MAX_EXTRA_IMAGES = 120
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
MAX_SEEDLESS_IMAGES = 2
SEEDLESS_SIZE = (1536, 1152)
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
_TABLES = (
"execution_run_outputs",
"execution_output_capture",
"execution_run_contexts",
"execution_context_capture",
"execution_runs",
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompt_library_state",
"prompts",
)
def concept_starred(prompt_index: int, round_index: int) -> bool:
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
return round_index == 0 and prompt_index % 2 == 0
def concept_seed(prompt_index: int, round_index: int) -> int:
"""The concept's sample seed (app.js:185)."""
return 42001 + prompt_index * 100 + round_index
def concept_filename(prompt: ConceptPrompt, round_index: int) -> str:
"""The concept's sample filename (app.js:187)."""
return f"{prompt.id}_{round_index + 1:05}.png"
def _graph(positive: str, negative: str, seed: int) -> dict:
"""A ComfyUI API prompt with the concept's facts (app.js:406-408).
The recipe is a Prompt Manager pair node, so with its embedded execution record
the inspector reports both roles as execution evidence, which the panel badges
"Execution recorded" (D21). One sampler keeps the seed unambiguous.
"""
return {
"1": {
"class_type": "PromptManagerPair",
"inputs": {"text": positive, "negative_text": negative, "clip": ["2", 1]},
},
"2": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": CONCEPT_MODEL},
},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["2", 0],
"positive": ["1", 0],
"negative": ["1", 1],
"seed": seed,
"steps": 28,
"cfg": 6.5,
"sampler_name": "dpmpp_2m",
"scheduler": "karras",
},
},
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
}
def _execution_record(positive: str, negative: str) -> dict:
"""The pair node's embedded execution evidence (utils/pair_execution.py)."""
record = {
"text": positive,
"negative_text": negative,
"positive_text": positive,
"negative_effective_text": negative,
"prepend_text": "",
"append_text": "",
}
return {"version": 1, "nodes": {"1": {"records": [record]}}}
def _snapshot(text: str) -> PromptSnapshot:
"""A recorded prompt snapshot whose raw and effective texts are ``text``."""
def known(value: str) -> TextEvidence:
return TextEvidence(value, "recorded", "runtime_return", None)
return PromptSnapshot(known(text), known(text), known(""), known(""))
def write_concept_png(path: Path, prompt_index: int, round_index: int) -> dict:
"""Write the concept's sample image ``round_index`` of prompt ``prompt_index``.
Returns its facts: variant, negative, seed and dimensions.
"""
prompt = CONCEPT_PROMPTS[prompt_index]
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
variant = round_index % len(prompt.negatives)
negative = prompt.negatives[variant][1]
seed = concept_seed(prompt_index, round_index)
width, height = size = CONCEPT_DIMENSIONS[index % len(CONCEPT_DIMENSIONS)]
sky, ground, light = prompt.color
info = PngInfo()
info.add_text("prompt", json.dumps(_graph(prompt.positive, negative, seed)))
info.add_text(
EXECUTION_KEY, json.dumps(_execution_record(prompt.positive, negative))
)
with Image.new("RGB", size, sky) as image:
draw = ImageDraw.Draw(image)
draw.rectangle((0, height * 2 // 3, width, height), fill=ground)
radius = min(width, height) // 9
left, top = width * 2 // 3, height // 6
draw.ellipse((left, top, left + radius, top + radius), fill=light)
image.save(path, pnginfo=info)
return {
"variant": variant,
"negative": negative,
"seed": seed,
"width": width,
"height": height,
}
def _clear(api: PromptManagerAPI, root: Path) -> None:
with api.db.model.get_connection() as conn:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
def _record_run(
api: PromptManagerAPI,
*,
epoch: str,
item: int,
context: RecipeContext,
output: OutputReference,
observed_at: int,
) -> str:
"""One finished execution with its recipe context and its one output."""
job = str(uuid5(UUID(SESSION), f"concept-job-{item}"))
start = make_start(SESSION, ((item, job, {}, {}), item), epoch, observed_at)
api.db.write_execution_start(start)
finish = make_finish(
SESSION,
item,
epoch,
SimpleNamespace(messages=[("execution_success", {"prompt_id": job})]),
observed_at + 10,
)
api.db.write_execution_finish(finish)
api.db.write_execution_contexts(
start.run_id, epoch, ContextBundle((context,), "complete", "captured")
)
api.db.write_execution_outputs_for_finish(
finish, OutputBundle((output,), "complete", "reported")
)
return start.run_id
def _write_seedless_png(path: Path) -> None:
"""A plain PNG with no metadata: no prompt, no seed, no link."""
with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
image.save(path)
def _bounded(name: str, value: int, maximum: int) -> None:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{name} must be an integer")
if not 0 <= value <= maximum:
raise ValueError(f"{name} is out of range")
def seed_workspace_concept(
api: PromptManagerAPI,
root: Path,
*,
extra_images: int = 0,
seedless_images: int = 0,
) -> dict:
"""Replace this server's disposable rows and files with the concept's data.
``extra_images`` continues the concept's rounds (same prompts, dimensions and
seed rule) after its 24 images, for bounded-rendering and Load more checks.
``seedless_images`` adds unlinked PNGs without metadata, newest of all.
"""
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
_clear(api, root)
db = api.db
epoch = db.get_execution_epoch()
prompts = []
for prompt in CONCEPT_PROMPTS:
first_name, first_text = prompt.negatives[0]
receipt = db.create_library_recipe(
prompt.positive,
first_text,
expected_epoch=epoch,
parent_metadata={
"title": prompt.title,
"category": prompt.category,
"tags": list(prompt.tags),
"rating": prompt.rating,
},
)
positive_id = receipt["positive"]["id"]
pair_ids = [receipt["pair"]["id"]]
db.update_prompt_pair_metadata(pair_ids[0], title=first_name)
for name, text in prompt.negatives[1:]:
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}
)
folder = root / "output" / "concepts"
folder.mkdir()
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
# The API package needs the host stubs the server installs before importing it.
from py.api.images import _favorite_relative_key, _favorite_root_key
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
now = time.time()
images, runs = [], []
for round_index in range(rounds):
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
if index >= total:
break
name = concept_filename(prompt, round_index)
path = folder / name
facts = write_concept_png(path, prompt_index, round_index)
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
# The concept lists images newest first in this order.
stamp = now - 60 - index * 60
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
pair_id = owner["pair_ids"][variant]
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
)
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
)
reference = OutputReference(
"4", 0, "output", "concepts", name, "posix", roots.output_key
)
runs.append(
_record_run(
api,
epoch=epoch,
item=index,
context=context,
output=reference,
observed_at=int(stamp * 1000),
)
)
images.append(
{
"filename": name,
"image_id": image_id,
"pair_id": pair_id,
"prompt": prompt.id,
"variant": variant,
"seed": seed,
"path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
)
seedless = []
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
path = folder / name
_write_seedless_png(path)
stamp = now - 10 - number * 10
os.utime(path, (stamp, stamp))
seedless.append({"filename": name, "path": f"concepts/{name}"})
with db.model.get_connection() as conn:
counts = conn.execute(
"SELECT COUNT(*), MIN(run_count), MAX(run_count) FROM prompts "
"WHERE id IN (SELECT prompt_id FROM prompt_library_state)"
).fetchone()
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
if tuple(counts) != (len(CONCEPT_PROMPTS), CONCEPT_RUN_COUNT, CONCEPT_RUN_COUNT):
raise RuntimeError(f"Concept fixture run counts are not real: {tuple(counts)}")
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
api.invalidate_gallery_cache()
return {
"epoch": epoch,
# The newest file's mtime (epoch seconds), for the D17 caption date.
"newest_modified": now - 10 if seedless_images else now - 60,
"seedless": seedless,
"prompts": prompts,
"images": images,
"runs": runs,
"tags": tags,
}
+3 -1
View File
@@ -89,7 +89,9 @@ def seed_workspace_inspector(
],
"links": [],
}
ambiguous = {**complete, "5": complete["3"]}
# Two branches that differ only in seed: facts merge per field (spec section 10).
branch = {**complete["3"], "inputs": {**complete["3"]["inputs"], "seed": 7}}
ambiguous = {**complete, "5": branch}
examples = (
("complete.png", {"prompt": complete, "workflow": workflow}),
("empty.png", {}),
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
+3 -3
View File
@@ -237,16 +237,16 @@ test.describe('public chronological history with the actual acknowledged host br
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
await expect(run.locator('[data-receipt]')).toHaveCount(4);
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
const viewer = workspace.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
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: `${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('pre').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('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
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 });
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
+3 -3
View File
@@ -28,7 +28,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
await history.getByRole('button',{name:'Select negative variant 1',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-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
@@ -60,7 +60,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
try {
// Created through the native editor entry action, with an explicitly empty negative.
await workspace.goto('/prompt_manager/workspace#/library');
await workspace.locator('.workspace-heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
const dialog=workspace.locator('dialog.library-editor');await expect(dialog.getByRole('button',{name:'Save',exact:true})).toBeEnabled();
await dialog.getByLabel('Title',{exact:true}).fill(title);await dialog.getByLabel('Positive prompt',{exact:true}).fill(positive);
await dialog.getByRole('button',{name:'Save',exact:true}).click();await expect(dialog).toHaveCount(0);
@@ -75,7 +75,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
pairMetadata:detail.variants.map(({id,title,category,tags,archived,negative_text})=>({id,title,category,tags,archived,negative_text}))};
};
const before=await evidence();expect(before.pairMetadata).toHaveLength(1);
const send=workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
const send=workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
for(const renderer of [testInfo.project.metadata.initialRenderer,!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);
+5 -3
View File
@@ -41,13 +41,15 @@ test('native image inspector previews and sends a pair into real visible widgets
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace#/images');
await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
// D15: Refresh is Import → Rescan.
await workspace.getByRole('button',{name:'Import',exact:true}).click();
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:`Open ${filename}`,exact:true}).click();
await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
const inspector=workspace.getByRole('region',{name:'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();
const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
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 viewer.getByText('Workflow & raw metadata',{exact:true}).click();
+11 -4
View File
@@ -17,14 +17,14 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(stats.total_prompts.toLocaleString());
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
const imageResponse = workspace.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
});
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
const images = await imageResponse;
expect(images.ok()).toBe(true);
@@ -34,9 +34,16 @@ test('workspace serves real library data beside either ComfyUI renderer', async
if (collection.images.length) {
await expect(workspace.locator('article[data-image-key]').first()).toBeVisible();
} else {
await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible();
await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
}
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
await workspace.evaluate(() => { location.hash = '/overview'; });
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
await workspace.evaluate(() => { location.hash = '/images'; });
await workspace.reload();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
+1 -1
View File
@@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them.
# Inspection parity fixtures
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f17.png`, each
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
graph, prompt text, model name and filename prefix in them is original synthetic
content written for this repository. Nothing comes from user output. They are
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

+427 -73
View File
@@ -6,9 +6,9 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
"reason_code": "no-sampler",
"state": "ambiguous",
"reason": "Multiple sampler branches",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
@@ -19,24 +19,24 @@
"value": null
},
"steps": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "20"
},
"cfg": {
"status": "ambiguous",
"status": "unresolved",
"value": null
},
"sampler": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "ambiguous",
"value": null
},
"model": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "flux-2-klein-9b-fp8.safetensors"
}
}
}
@@ -48,12 +48,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
"reason_code": "no-sampler",
"state": "absent",
"reason": "Guider has no negative input",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur, Scene 0: unique shot description soft winter light,",
"negative": "<absent>",
"candidate_count": 0,
"facts": {
"seed": {
@@ -61,8 +61,8 @@
"value": "1000"
},
"steps": {
"status": "unresolved",
"value": null
"status": "known",
"value": "20"
},
"cfg": {
"status": "not-recorded",
@@ -73,8 +73,8 @@
"value": "euler"
},
"scheduler": {
"status": "unresolved",
"value": null
"status": "known",
"value": "Flux2Scheduler"
},
"model": {
"status": "known",
@@ -258,12 +258,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -300,12 +300,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -342,12 +342,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -385,8 +385,8 @@
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"reason": "Combined conditioning has different texts",
"reason_code": "composite",
"selection": null,
"positive": null,
"negative": null,
@@ -426,12 +426,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -510,12 +510,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced, CFGGuider)",
"reason_code": "no-sampler",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -603,28 +603,28 @@
"candidate_count": 0,
"facts": {
"seed": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "42"
},
"steps": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "25"
},
"cfg": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "normal"
},
"model": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
@@ -636,12 +636,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported latent ancestry",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -678,12 +678,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported latent ancestry",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -888,12 +888,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced)",
"reason_code": "no-sampler",
"state": "absent",
"reason": "Guider has no negative input",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "<absent>",
"candidate_count": 0,
"facts": {
"seed": {
@@ -923,6 +923,258 @@
}
}
},
{
"name": "f19-inspection",
"fixture": "f19",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "ambiguous",
"reason": "Sampler stages have different prompts",
"reason_code": "multistage-differs",
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
"status": "ambiguous",
"value": null
},
"steps": {
"status": "ambiguous",
"value": null
},
"cfg": {
"status": "ambiguous",
"value": null
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f20-inspection",
"fixture": "f20",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f21-inspection",
"fixture": "f21",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Text is decided at run time",
"reason_code": "runtime-value",
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f22-inspection",
"fixture": "f22",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Text is decided at run time",
"reason_code": "runtime-value",
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f23-inspection",
"fixture": "f23",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f24-inspection",
"fixture": "f24",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f01-default",
"fixture": "f01",
@@ -1296,5 +1548,107 @@
"candidate_count": 0,
"facts": null
}
},
{
"name": "f19-default",
"fixture": "f19",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": null
}
},
{
"name": "f20-default",
"fixture": "f20",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f21-default",
"fixture": "f21",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f22-default",
"fixture": "f22",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f23-default",
"fixture": "f23",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f24-default",
"fixture": "f24",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
}
]
+461
View File
@@ -0,0 +1,461 @@
"""Named workflow cases shared by the generation-parameter parser tests.
Each case is the raw metadata records a file would carry, as text, so the cases
exercise ``parse_js_json`` as well as the tracing. The JS parity test runs every
case through the legacy browser parser too. Prompt texts and seeds are invented.
"""
import json
def _text(value):
return None if value is None else json.dumps(value)
def case(prompt=None, workflow=None):
return {"prompt": _text(prompt), "workflow": _text(workflow)}
def encoder(text, clip=("4", 1)):
return {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": list(clip)},
}
def ksampler(positive, negative, model=("4", 0), **settings):
inputs = {
"seed": 5,
"steps": 20,
"cfg": 7,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": list(model),
"positive": list(positive),
"negative": list(negative),
"latent_image": ["2", 0],
}
inputs.update(settings)
return {"class_type": "KSampler", "inputs": inputs}
CHECKPOINT = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "example.safetensors"},
}
LATENT = {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
}
def _basic():
return {
"4": CHECKPOINT,
"2": LATENT,
"6": encoder("a cat on a windowsill"),
"7": encoder("blurry, watermark"),
"3": ksampler(("6", 0), ("7", 0)),
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
}
def _prompt_manager(text, prepend="", append=""):
return {
"class_type": "PromptManager",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"prepend_text": prepend,
"append_text": append,
"clip": ["4", 1],
},
}
def _shared_prompt_pipeline():
"""One PromptManager pair feeds a base pass, an upscale pass and a detailer."""
return {
"1": CHECKPOINT,
"5": ksampler(
("25", 0),
("26", 0),
("1", 0),
seed=111,
steps=4,
cfg=1.0,
sampler_name="lcm",
latent_image=["20", 0],
),
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
},
"11": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["6", 0], "vae": ["1", 2]},
},
"12": ksampler(
("25", 0),
("26", 0),
("1", 0),
seed=222,
steps=6,
cfg=1.0,
sampler_name="lcm",
denoise=0.45,
latent_image=["11", 0],
),
"13": {
"class_type": "VAEDecode",
"inputs": {"samples": ["12", 0], "vae": ["1", 2]},
},
"17": {
"class_type": "FaceDetailer",
"inputs": {
"seed": 333,
"steps": 4,
"cfg": 1.0,
"sampler_name": "lcm",
"image": ["13", 0],
"model": ["1", 0],
"clip": ["1", 1],
"vae": ["1", 2],
"positive": ["25", 0],
"negative": ["26", 0],
},
},
"20": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 640, "height": 1536, "batch_size": 1},
},
"25": _prompt_manager("a lighthouse at dusk, oil painting"),
"26": _prompt_manager("blurry, watermark"),
"48": {
"class_type": "KikoSaveImage",
"inputs": {"filename_prefix": ["49", 0], "images": ["17", 0]},
},
"49": {
"class_type": "KikoProjectFilePath",
"inputs": {"project_name": "example"},
},
}
def _custom_sampler(guider):
return {
"13": CHECKPOINT,
"20": encoder("a red fox in snow", clip=("13", 1)),
"21": encoder("lowres", clip=("13", 1)),
"22": guider,
"25": {"class_type": "RandomNoise", "inputs": {"noise_seed": 42}},
"16": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "dpmpp_2m"}},
"17": {
"class_type": "BasicScheduler",
"inputs": {
"scheduler": "karras",
"steps": 8,
"denoise": 1.0,
"model": ["13", 0],
},
},
"1300:8": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["25", 0],
"guider": ["22", 0],
"sampler": ["16", 0],
"sigmas": ["17", 0],
"latent_image": ["5", 0],
},
},
}
def _workflow_only():
return {
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [],
"widgets_values": ["example.safetensors"],
},
{
"id": 6,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 3}],
"widgets_values": ["a tidepool at noon"],
},
{
"id": 7,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 5}],
"widgets_values": ["noisy"],
},
{
"id": 10,
"type": "Reroute",
"mode": 0,
"inputs": [{"name": "", "link": 7}],
},
{
"id": 3,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "link": 1},
{"name": "positive", "link": 8},
{"name": "negative", "link": 4},
{"name": "latent_image", "link": 2},
],
"widgets_values": [314, "randomize", 25, 6.5, "dpmpp_2m", "karras", 1],
},
{
"id": 11,
"type": "CLIPTextEncode",
"mode": 2,
"inputs": [],
"widgets_values": ["muted node text"],
},
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[7, 6, 0, 10, 0, "CONDITIONING"],
[8, 10, 0, 3, 1, "CONDITIONING"],
[4, 7, 0, 3, 2, "CONDITIONING"],
{
"id": 2,
"origin_id": 99,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT",
},
],
}
def _nested(depth):
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
value = ["leaf", 7]
for _ in range(depth):
value = [value, None]
return value
def _chain(length):
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
previous = "1"
for index in range(length):
node_id = str(100 + index)
graph[node_id] = {
"class_type": "ConditioningPass",
"inputs": {"conditioning": [previous, 0]},
}
previous = node_id
graph["3"] = ksampler((previous, 0), ("1", 0))
return graph
CASES = {
"basic": case(_basic()),
"prompt-manager-prepend-append": case(
{
"4": CHECKPOINT,
"6": _prompt_manager("a cat", prepend="masterpiece", append="4k"),
"7": _prompt_manager(
"masterpiece, a dog", prepend="masterpiece", append="4k"
),
"3": ksampler(("6", 0), ("7", 0)),
}
),
"linked-text-concat": case(
{
"4": CHECKPOINT,
"10": {"class_type": "PrimitiveString", "inputs": {"value": "first part"}},
"11": {
"class_type": "StringConcatenate",
"inputs": {
"string_a": ["10", 0],
"string_b": "second part",
"delimiter": ", ",
"seed": 3,
"string_c": " ",
},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["11", 0], "clip": ["4", 1]},
},
"7": encoder(""),
"3": ksampler(("6", 0), ("7", 0)),
}
),
"prompt-manager-pair": case(
{
"4": CHECKPOINT,
"8": {
"class_type": "PromptManagerPair",
"inputs": {
"text": "pair positive",
"negative_text": "pair negative",
"prepend_text": "",
"append_text": "",
"clip": ["4", 1],
},
},
"3": ksampler(("8", 0), ("8", 1)),
}
),
"conditioning-passthrough": case(
{
"4": CHECKPOINT,
"6": encoder("through a controlnet"),
"7": encoder("negative through it"),
"12": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": ["6", 0],
"negative": ["7", 0],
"control_net": ["13", 0],
},
},
"3": ksampler(("12", 0), ("12", 1)),
}
),
"first-sampler-in-js-key-order": case(
{
"4": CHECKPOINT,
"6": encoder("from sampler twelve"),
"7": encoder("from sampler five"),
"x": ksampler(("6", 0), ("6", 0)),
"12": ksampler(("6", 0), ("6", 0), seed=12),
"5": ksampler(("7", 0), ("7", 0), seed=55),
}
),
"custom-sampler-cfg-guider": case(
_custom_sampler(
{
"class_type": "CFGGuider",
"inputs": {
"model": ["13", 0],
"positive": ["20", 0],
"negative": ["21", 0],
"cfg": 3.5,
},
}
)
),
"custom-sampler-basic-guider": case(
_custom_sampler(
{
"class_type": "BasicGuider",
"inputs": {"model": ["13", 0], "conditioning": ["20", 0]},
}
)
),
"no-sampler-loose-settings": case(
{
"4": CHECKPOINT,
"3": {
"class_type": "MyKSamplerVariant",
"inputs": {
"noise_seed": 9,
"steps": 12,
"cfg": 2,
"sampler_name": "ddim",
},
},
}
),
"primitive-seed": case(
{
"4": CHECKPOINT,
"6": encoder("seeded"),
"30": {
"class_type": "Seed (rgthree)",
"inputs": {"other": "x", "seed": 77},
},
"3": ksampler(("6", 0), ("6", 0), seed=["30", 0]),
}
),
"workflow-only": case(workflow=_workflow_only()),
"overwritten-prompt-manager-text": case(
{
"4": CHECKPOINT,
"25": _prompt_manager("same saved text"),
"26": _prompt_manager("same saved text"),
"3": ksampler(("25", 0), ("26", 0)),
},
{
"nodes": [
{
"id": 25,
"type": "PromptManager",
"widgets_values": ["the real positive"],
},
{
"id": 26,
"type": "PromptManager",
"widgets_values": ["the real negative"],
},
],
"links": [],
},
),
"conditioning-cycle": case(
{
"4": CHECKPOINT,
"20": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["21", 0]},
},
"21": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["20", 0]},
},
"3": ksampler(("20", 0), ("21", 0)),
}
),
"chain-within-visit-limit": case(_chain(450)),
"chain-beyond-visit-limit": case(_chain(600)),
"chain-at-last-visit": case(_chain(499)),
"chain-one-past-last-visit": case(_chain(500)),
"blank-text-and-zero-seed": case(
{
"4": CHECKPOINT,
"6": encoder(" "),
"7": encoder("kept negative"),
"3": ksampler(("6", 0), ("7", 0), seed=0),
}
),
"shared-prompt-pipeline": case(_shared_prompt_pipeline()),
"null-workflow-node": case(_basic(), {"nodes": [None], "links": []}),
"unparsable-records": {"prompt": '{"3": NaN}', "workflow": "not json"},
"seed-beyond-double-precision": case(
{
"4": CHECKPOINT,
"6": encoder("x"),
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
}
),
"deeply-nested-values": case(
{
"4": CHECKPOINT,
"6": encoder("nested neighbours"),
"3": ksampler(("6", 0), ("6", 0)),
"8": {"class_type": _nested(900), "inputs": {"text": "x"}},
},
{"nodes": [{"id": _nested(900), "type": _nested(900)}], "links": []},
),
"nothing": case(),
}
+116 -1
View File
@@ -2,7 +2,7 @@
Every graph here is original synthetic content (CC0): neutral texts, generic model
file names and invented filename prefixes. Nothing is copied from user outputs.
The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``).
The shapes reproduce the parity audit's fixture numbering (``f01``..``f24``).
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
@@ -66,6 +66,12 @@ COMMITTED = (
"f15",
"f16",
"f17",
"f19",
"f20",
"f21",
"f22",
"f23",
"f24",
)
# Built in a temporary directory at test time (too large to commit).
TEST_TIME = ("f01", "f04", "f05")
@@ -100,6 +106,10 @@ def _enc(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
def _linked_enc(link):
return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
def _ksampler_graph(positive, negative, sampler="KSampler", latent=None, extra=None):
graph = {
"4": {
@@ -691,6 +701,105 @@ def _f17():
return _basic(graph, _ui(graph))
DETAILER_POS = "a red fox in snow, sharp detailed face"
def _f19():
"""KSampler -> VAEDecode -> FaceDetailer -> SaveImage; the detailer re-prompts."""
graph = _ksampler_graph(_enc(POS), _enc(NEG))
graph["10"] = _enc(DETAILER_POS)
graph["50"] = {
"class_type": "FaceDetailer",
"inputs": {
"image": ["8", 0],
"model": ["4", 0],
"clip": ["4", 1],
"vae": ["4", 2],
"positive": ["10", 0],
"negative": ["7", 0],
"seed": 7,
"steps": 12,
"cfg": 4.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.4,
},
}
graph["9"]["inputs"]["images"] = ["50", 0]
return _basic(graph)
def _f20():
"""``easy ifElse`` with a literal boolean picks the positive branch."""
select = {
"class_type": "easy ifElse",
"inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]},
}
extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")}
return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
def _f21():
"""``JoinStringMulti`` with ``return_list: true`` feeds the positive encoder."""
join = {
"class_type": "JoinStringMulti",
"inputs": {
"inputcount": 2,
"delimiter": ", ",
"return_list": True,
"string_1": ["11", 0],
"string_2": ["12", 0],
},
}
extra = {
"10": join,
"11": {"class_type": "PrimitiveString", "inputs": {"value": "a red fox"}},
"12": {"class_type": "PrimitiveString", "inputs": {"value": "in snow"}},
}
positive = _linked_enc(["10", 0])
return _basic(_ksampler_graph(positive, _enc(NEG), extra=extra))
def _f22():
"""``PromptSearchList`` (database search at run time) feeds the positive."""
search = {
"class_type": "PromptSearchList",
"inputs": {
"text": "fox",
"category": "",
"tags": "",
"min_rating": 0,
"limit": 50,
},
}
positive = _linked_enc(["10", 0])
return _basic(_ksampler_graph(positive, _enc(NEG), extra={"10": search}))
def _f24():
"""``TextEncodeQwenImage21``: slot 0 is the positive, slot 1 the negative."""
qwen = {
"class_type": "TextEncodeQwenImage21",
"inputs": {
"clip": ["4", 1],
"prompt": POS,
"negative_prompt": NEG,
"vae": ["4", 2],
"resolution": 1024,
},
}
graph = _ksampler_graph(qwen, None)
del graph["7"]
graph["3"]["inputs"]["negative"] = ["6", 1]
return _basic(graph)
def _f23():
"""The negative is ``ConditioningZeroOut`` of the positive encoder."""
zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
return _basic(_ksampler_graph(_enc(POS), zero))
_BUILDERS = {
"f01": _f01,
"f01b": _f01b,
@@ -714,6 +823,12 @@ _BUILDERS = {
"f15": _f15,
"f16": _f16,
"f17": _f17,
"f19": _f19,
"f20": _f20,
"f21": _f21,
"f22": _f22,
"f23": _f23,
"f24": _f24,
}
+13
View File
@@ -163,6 +163,19 @@ test("post, put and del send JSON bodies with the right method", async () => {
await Promise.all([p1, p2, p3]);
});
test("del sends an optional JSON body", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const controller = new AbortController();
const pending = api.del("/prompt_manager/images/favorite", { body: { root_index: 1, path: "a/b.png" }, signal: controller.signal });
assert.equal(fetch.calls[0].init.method, "DELETE");
assert.equal(fetch.calls[0].init.body, JSON.stringify({ root_index: 1, path: "a/b.png" }));
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
assert.equal(fetch.calls[0].init.signal, controller.signal);
fetch.calls[0].resolve(jsonResponse(200, { success: true }));
await pending;
});
test("network failures propagate as ordinary errors", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
+226
View File
@@ -164,6 +164,232 @@ test("facts require model output ancestry with supported loaders and passthrough
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
});
// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
function detailerFactGraph(detailer = {}) {
const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
return graph;
}
const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
test("facts use resolution.sampler_id for detailer-stage nodes", () => {
// Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
assert.deepEqual(statusValues(resolved), {
seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
// The upstream KSampler is also accepted when it is the one resolved.
assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
// Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
assert.deepEqual(statusValues(ambiguous), {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
// A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
// Two detailer stages over one KSampler: three stages merge.
const chained = detailerFactGraph({ seed: "42" });
chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
// Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
for (const state of ["resolved", "ambiguous"]) {
const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
}
}
// Stage ancestry that cannot be walked is unresolved, not a partial merge.
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
const unlinked = detailerFactGraph({ image: "not a link" });
for (const graph of [cyclic, unlinked]) {
assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
}
const wide = detailerFactGraph();
let previous = ["s", 0];
for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
});
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
// Equal independent branches agree on every field once the server says ambiguous;
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
const equal = factGraph(); equal.other = structuredClone(equal.s);
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
}
const many = factGraph();
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
});
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
const shaped = [
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
];
for (const inputs of shaped) {
const graph = detailerFactGraph();
graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["efficient", 0];
for (const sampler_id of ["d", null]) {
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
}
}
// Model patches (model alone, or with clip) and listed table classes are walked through.
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
const graph = detailerFactGraph();
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["hop", 0];
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
}
});
test("unresolved and absent results over several stages merge facts per field", () => {
// Review I-1: the run's settings are facts even when its prompt is not, e.g. an
// upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
const merged = {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
};
for (const resolution of [
{ state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
{ state: "unresolved", sampler_id: null, reason_code: "malformed" },
{ state: "absent", sampler_id: "d" },
]) {
const facts = factsFor(detailerFactGraph(), { resolution });
assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
assert.ok(factFields.every(field => facts[field].sampler_id === null));
}
// A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
// One listed sampler keeps today's rule: its own facts, attributed to it.
const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
});
test("a named sampler limits absent and unresolved facts to its own stages", () => {
// Codex P2: the server dropped a preview-only detailer and named the saved sampler, so
// the detailer's settings never leak into the saved file's facts.
const graph = detailerFactGraph();
graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } };
graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } };
const own = {
seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
};
for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state);
// Without a named sampler every listed sampler still merges.
assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous");
});
test("stage walks follow only the resolver's links", () => {
// Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
for (const slot of [-1, 0.5, "0"]) {
const graph = detailerFactGraph();
graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
graph.decode.inputs.extra = ["eff", slot];
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
const shaped = detailerFactGraph();
shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
shaped.d.inputs.image = ["eff", 0];
assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
}
const linked = detailerFactGraph();
linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
linked.decode.inputs.extra = ["eff", 0];
assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
});
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
const graph = customFactGraph();
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
const facts = factsFor(graph);
assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
for (const slot of [1, -1]) {
const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
}
// Its own inputs cannot replace the node type, and steps keep exact-integer rules.
const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
assert.equal(factsFor(named).steps.status, "unresolved");
});
test("ModelSampling passthroughs reach UNETLoader", () => {
const graph = customFactGraph();
graph.g.inputs.model = ["flux", 0];
graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
for (const node of ["flux", "aura", "sd3"]) {
const wrongSlot = structuredClone(graph);
const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
wrongSlot[parent].inputs.model = [node, 1];
assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
}
});
test("vectors.json pins full facts for every inspection fixture", () => {
const fs = require("node:fs");
const path = require("node:path");
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
const inspection = vectors.filter(vector => vector.profile === "inspection");
assert.ok(inspection.length > 0);
const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
for (const vector of inspection) {
const facts = vector.expect.facts;
assert.deepEqual(Object.keys(facts), factFields, vector.name);
for (const field of factFields) {
assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
}
}
// f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
const f19 = inspection.find(vector => vector.fixture === "f19");
assert.equal(f19.expect.state, "ambiguous");
assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
});
});
test("wide workflow lookups retain selected core sampler facts", () => {
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
let linkId = 1;
+119
View File
@@ -0,0 +1,119 @@
// Run with: node --test tests/js/
// Legacy parser rules ported to the inspection facts (#240, Task 2.7b). Synthetic texts.
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
const fields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
const FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5";
function parameterFacts(parameters) {
const facts = extractGenerationFacts({
prompt_graph: { status: "absent", value: null },
workflow_graph: { status: "absent", value: null },
parameters,
recordFormats: { parameters: "original-text" },
metadataStatus: "available",
});
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
}
const FOUR_KNOWN = {
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "Euler"],
scheduler: ["not-recorded", null], model: ["not-recorded", null],
};
const ALL_UNRESOLVED = Object.fromEntries(fields.map(field => [field, ["unresolved", null]]));
test("R4: text starting with the negative delimiter has no positive, as with a leading newline", () => {
for (const text of [`Negative prompt: bad\n${FOOTER}`, `\nNegative prompt: bad\n${FOOTER}`, `Negative prompt: a\nNegative prompt: b\n${FOOTER}`]) {
assert.deepEqual(parameterFacts(text), ALL_UNRESOLVED, text);
}
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
});
test("R5: template records after the footer and a bare Steps footer", () => {
const base = `a cat\nNegative prompt: bad\n${FOOTER}`;
for (const trailer of ["\nTemplate: a {cat|dog}", "\nTemplate: a {cat|dog}\nNegative Template: bad", "\nNegative Template: bad\n"]) {
assert.deepEqual(parameterFacts(base + trailer), FOUR_KNOWN, trailer);
}
for (const trailer of ["\nTemplate: a\nTemplate: b", "\nNegative Template: bad\nTemplate: a", "\nTemplate: a\nmore", "\ntrailing"]) {
assert.deepEqual(parameterFacts(base + trailer), ALL_UNRESOLVED, trailer);
}
const bare = parameterFacts("a cat\nNegative prompt: bad\nSteps: 20\nTemplate: a {cat|dog}");
assert.deepEqual(bare.steps, ["known", "20"]);
assert.ok(["seed", "cfg", "sampler", "scheduler", "model"].every(field => bare[field][0] === "not-recorded"));
// A full footer wins over a bare `Steps: N` line in the prompt; two bare ones are not unique.
assert.deepEqual(parameterFacts(`a cat\nSteps: 3\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
assert.deepEqual(parameterFacts("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20"), ALL_UNRESOLVED);
});
test("R10: commas inside quoted settings values do not split them", () => {
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}, ADetailer prompt: "face, smile"`), FOUR_KNOWN);
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "say \\"hi, there\\"", Model: base`), { ...FOUR_KNOWN, model: ["known", "base"] });
// A quoted value of a fact is its JSON string.
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Model: "base, v2"`).model, ["known", "base, v2"]);
// Unquoted JSON (comfy-image-saver hashes) keeps the historical split, field by field.
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Hashes: {"model": "abc", "lora:x": "def"}, Model: base`).model, ["known", "base"]);
// An unbalanced quote is not a quoted value: the historical split, as A1111 reads it.
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "open, Model: base`).model, ["known", "base"]);
});
function graphFacts(graph, resolution) {
const facts = extractGenerationFacts({
prompt_graph: { status: "parsed", value: graph },
workflow_graph: { status: "absent", value: null },
metadataStatus: "available",
resolution,
});
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
}
function modelGraph(between) {
return {
ckpt: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
hop: { class_type: between, inputs: { model: ["ckpt", 0], clip: ["ckpt", 1] } },
s: { class_type: "KSampler", inputs: { seed: 5, steps: 20, cfg: 7, sampler_name: "euler", scheduler: "normal", model: ["hop", 0] } },
};
}
test("R9: model pass-throughs verified in their sources lead to the loader", () => {
for (const name of ["Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance"]) {
assert.deepEqual(graphFacts(modelGraph(name)).model, ["known", "base.safetensors"], name);
// Only output 0 is the model.
const other = modelGraph(name); other.s.inputs.model = ["hop", 1];
assert.deepEqual(graphFacts(other).model, ["unresolved", null], name);
}
assert.deepEqual(graphFacts(modelGraph("ModelMergeSDXL")).model, ["unresolved", null]);
});
test("R6: upscale and SEGS detailers supply stage facts like FaceDetailer", () => {
for (const [class_type, image] of [["UltimateSDUpscale", "image"], ["UltimateSDUpscaleNoUpscale", "upscaled_image"], ["DetailerForEach", "image"]]) {
const graph = modelGraph("CFGNorm");
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["ckpt", 2] } };
graph.d = { class_type, inputs: { [image]: ["decode", 0], model: ["hop", 0], seed: 7, steps: 12, cfg: 7, sampler_name: "euler", scheduler: "normal" } };
assert.deepEqual(graphFacts(graph, { state: "resolved", sampler_id: "d" }).seed, ["known", "7"], class_type);
const merged = graphFacts(graph, { state: "ambiguous", sampler_id: "d" });
assert.deepEqual([merged.seed[0], merged.steps[0], merged.cfg], ["ambiguous", "ambiguous", ["known", "7"]], class_type);
}
});
test("R6: upstream-verified samplers supply facts unless an override input is set", () => {
for (const [class_type, overrides] of [["ClownsharKSampler_Beta", ["options", "sigmas"]], ["KSampler (Efficient)", ["script"]], ["ttN KSampler_v2", ["adv_xyPlot"]]]) {
const graph = modelGraph("CR Apply LoRA Stack");
graph.s.class_type = class_type;
assert.deepEqual(graphFacts(graph), {
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "euler"],
scheduler: ["known", "normal"], model: ["known", "base.safetensors"],
}, class_type);
for (const name of overrides) {
const overridden = structuredClone(graph);
overridden.s.inputs[name] = ["script", 0];
overridden.script = { class_type: "SomeScript", inputs: {} };
assert.deepEqual(graphFacts(overridden), ALL_UNRESOLVED, `${class_type} ${name}`);
for (const unset of [null, ""]) {
overridden.s.inputs[name] = unset;
assert.equal(graphFacts(overridden).seed[0], "known", `${class_type} ${name} ${unset}`);
}
}
}
});
@@ -0,0 +1,79 @@
// Run with: node --test tests/js/
// comfy-metadata.js stays a classic script for the legacy pages, so its facts
// tables are inline literals. This test keeps them equal to the one semantics
// table (web/js/inspection-semantics.json) that Python also loads.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const metadata = require("../../web/js/comfy-metadata.js");
const tablePath = path.join(__dirname, "..", "..", "web", "js", "inspection-semantics.json");
const semantics = JSON.parse(fs.readFileSync(tablePath, "utf8"));
function plain(value) {
if (value instanceof Set) return [...value];
if (Array.isArray(value)) return value.map(plain);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)]));
return value;
}
test("facts tables equal semantics.facts", () => {
assert.equal(typeof metadata._factsTables, "object", "comfy-metadata exposes its facts tables for this test");
assert.deepStrictEqual(plain(metadata._factsTables), semantics.facts);
});
test("facts guider providers are guiders in the semantics table", () => {
const providers = Object.entries(semantics.facts.configuration_outputs).filter(([, adapter]) => adapter.kind === "guider");
assert.ok(providers.length > 0);
for (const [name] of providers) assert.ok(Object.hasOwn(semantics.guiders, name), name);
});
test("the facts tables are not part of the legacy page contract", () => {
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
assert.ok(Object.isFrozen(metadata._factsTables));
});
// Task 2.1 checked only `facts`; the facts code also reads the samplers table
// (which classes are samplers, their facts shape and a detailer's image input).
test("sampler facts table equals semantics.samplers", () => {
assert.equal(typeof metadata._samplerTable, "object", "comfy-metadata exposes its sampler table for this test");
// Task 2.7b: plus `fact_overrides`; `prompt_overrides` is read only by the resolver.
const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, {
facts: entry.facts,
...(entry.image_stage === undefined ? {} : { image_stage: entry.image_stage }),
...(entry.fact_overrides === undefined ? {} : { fact_overrides: entry.fact_overrides }),
}]));
assert.deepStrictEqual(plain(metadata._samplerTable), expected);
assert.ok(Object.isFrozen(metadata._samplerTable));
});
test("detailer-stage samplers, and only they, name an image stage", () => {
for (const [name, entry] of Object.entries(semantics.samplers)) {
assert.equal(Object.hasOwn(metadata._samplerTable[name], "image_stage"), entry.strategy === "detailer-stage", name);
}
});
// The stage walk treats every table class as known (the resolver's `all_classes`).
test("stage walk table classes equal every class in the semantics table", () => {
const classes = new Set();
for (const category of ["samplers", "guiders", "selectors", "composites", "savers"]) Object.keys(semantics[category]).forEach(name => classes.add(name));
for (const category of ["conditioning", "encoders", "strings"]) semantics[category].forEach(entry => classes.add(entry.class));
for (const category of ["runtime", "non_file_outputs"]) semantics[category].forEach(name => classes.add(name));
assert.deepStrictEqual([...metadata._tableClasses].sort(), [...classes].sort());
});
// Review I-2: one source for the stage-walk input lists (Python reads them there too).
test("stage walk inputs equal semantics.stage_inputs", () => {
assert.equal(typeof semantics.stage_inputs, "object");
assert.equal(typeof metadata._stageInputs, "object", "comfy-metadata exposes its stage inputs for this test");
assert.deepStrictEqual(plain(metadata._stageInputs), semantics.stage_inputs);
assert.ok(Object.isFrozen(metadata._stageInputs));
});
// Review M-3: JS treats every samplers entry as a candidate; the server only these strategies.
test("every samplers entry is a candidate strategy", () => {
for (const [name, entry] of Object.entries(semantics.samplers)) {
assert.ok(["ksampler", "custom-advanced", "detailer-stage"].includes(entry.strategy), name);
}
});
@@ -3,7 +3,10 @@
// (web/metadata.html, web/js/gallery.js) show today on the synthetic inspection
// parity fixtures, so the #240 workspace work cannot change them by accident.
// Some answers are known to be wrong (f01b shows the preview-only detailer text
// as both roles; f16 shows the last of two conflicting prompt chunks). They are
// as both roles; f16 shows the last of two conflicting prompt chunks; f19 shows the
// upstream sampler's prompt and settings, not the detailer's; f23 shows the positive
// text as the zeroed negative; f21 joins a list output; f22 shows PromptSearchList's
// search text as the prompt). They are
// recorded as-is and intentionally unchanged (spec section 11).
const test = require("node:test");
const assert = require("node:assert/strict");
@@ -56,6 +59,12 @@ const EXPECTED = {
f15: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f16: { positivePrompt: "a grey wolf in fog", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" },
f19: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f21: { positivePrompt: "a red fox, in snow", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f22: { positivePrompt: "fox", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f24: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
};
test("legacy extractGenerationParams characterization covers every committed fixture", () => {
+73
View File
@@ -70,3 +70,76 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
}finally{scope.dispose();f.restore();}
});
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
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);
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);
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).';
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
assert.deepEqual(statusLines(f,container),[line,line]);
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined).length,0,'no action implies a prompt exists');
}finally{scope.dispose();f.restore();}
});
test('runtime and composite statuses',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const runtime='Prompt is chosen at run time (PromptSearchList).',composite='Prompt combines several texts (ConditioningCombine).';
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(runtime),negative:unresolved(runtime)}});
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);
}finally{scope.dispose();f.restore();}
});
test('limit status',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const line='Prompt exceeds inspection limits.';
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
assert.deepEqual(statusLines(f,container),[line,line]);
}finally{scope.dispose();f.restore();}
});
test('roles without a usable line keep the status copy, and a candidate is never shown as a prompt',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(''),negative:{status:'ambiguous',text:'candidate text',source:null}}});
assert.deepEqual(statusLines(f,container),['Prompt could not be resolved.','Multiple possible prompts. Source could not be determined.']);
assert.equal(f.walk(container,node=>node.textContent==='candidate text').length,0);
}finally{scope.dispose();f.restore();}
});
test('class names in status lines render as text, never markup',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const line='Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
assert.deepEqual(statusLines(f,container),[line,line]);
assert.equal(f.walk(container,node=>node.tagName==='img').length,0);
assert.equal(f.walk(container,node=>Object.hasOwn(node,'innerHTML')).length,0);
}finally{scope.dispose();f.restore();}
});
test('a context label names nested prompt regions and actions apart from the frozen blocks while the visible heading stays',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
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);
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']);
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();}
});
@@ -0,0 +1,32 @@
// Runs the legacy browser parser (comfy-metadata.js extractGenerationParams) on
// one JSON case per input line and prints one JSON result per line. The Python
// port (utils/generation_params.py) must produce the same results.
//
// Input line: {"prompt": <text or null>, "workflow": <text or null>}
// Each present record is parsed with JSON.parse; a record that fails
// to parse is treated as absent, as the browser pages do.
// Output line: {"ok": true, "result": {...}} or {"ok": false} when the parser throws.
import path from 'node:path';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { extractGenerationParams } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
function parsed(text) {
if (typeof text !== 'string') return undefined;
try { return JSON.parse(text); } catch { return undefined; }
}
for (const line of fs.readFileSync(0, 'utf8').split('\n')) {
if (!line.trim()) continue;
const item = JSON.parse(line), data = {};
const prompt = parsed(item.prompt), workflow = parsed(item.workflow);
if (prompt !== undefined) data.prompt = prompt;
if (workflow !== undefined) data.workflow = workflow;
let out;
try { out = { ok: true, result: extractGenerationParams(data) }; } catch { out = { ok: false }; }
process.stdout.write(JSON.stringify(out) + '\n');
}
+39
View File
@@ -0,0 +1,39 @@
// The seed the workspace inspector shows for encoded inspection responses, through
// its own path: projectInspectionPayload with the server facts and the legacy
// parser fill (comfy-metadata.js, passed explicitly; Node has no window global).
// Used by tests/test_api_workspace_images.py to compare card and inspector seeds.
// stdin: {listing, inspections: [{name, body}]}; each body is the exact response
// JSON text, and listing (optional) is the exact workspace listing response text.
// stdout: {inspector: {name: {status, value}}, card: {path: String(seed) | null}}.
// Inspector status is 'error' where the page cannot project the record. The card
// values come from JSON.parse of the listing, as the browser reads them, so a
// seed sent as a JSON number past 2**53 shows up rounded here.
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
const here = path.dirname(fileURLToPath(import.meta.url));
const web = path.join(here, '..', '..', '..', 'web', 'js');
const require = createRequire(import.meta.url);
const parser = require(path.join(web, 'comfy-metadata.js'));
const { projectInspectionPayload } = await import(pathToFileURL(path.join(web, 'workspace', 'inspection-state.mjs')).href);
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const { listing = null, inspections } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const inspector = {};
for (const { name, body } of inspections) {
const identity = { kind: 'rooted-file', key: `0:${name}`, root_index: 0, path: name, root_scope: null };
try {
const record = projectInspectionPayload(JSON.parse(body), identity, null, parser.extractGenerationFacts, false, parser);
inspector[name] = { status: record.generation.seed.status, value: record.generation.seed.value };
} catch (error) {
// The page shows no record here, so no seed either.
inspector[name] = { status: 'error', value: String(error && error.name) };
}
}
const card = {};
for (const item of listing === null ? [] : JSON.parse(listing).images) {
card[item.path] = item.seed === null || item.seed === undefined ? null : String(item.seed);
}
process.stdout.write(JSON.stringify({ inspector, card }));
@@ -19,7 +19,7 @@ test('layouts preserve data order, remain within width and never overlap', async
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
const {layoutImages}=await source;
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0});
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],columns:1,height:0,maxHeight:0});
for(const mode of ['grid','list','masonry']) {
const result=layoutImages([{}, {width:0,height:null}],1,mode);
assert.ok(Number.isFinite(result.height));
@@ -74,64 +74,78 @@ test('binary-searched windows handle boundaries, empty collections and focus wit
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
const {layoutImages,visibleImages}=await source;
const geometry=layoutImages([{}, {}, {}],10000,'grid',20000);
assert.equal(geometry.maxHeight,7554.5);
const geometry=layoutImages([{}, {}, {}],10000,'grid',{columns:1});
assert.equal(geometry.maxHeight,9998/1.33+56);
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
});
// The concept's mountMasonry reads the same two values (app.js 530-534); its breakpoints live in concept.css only.
function computedGrid(columns, gap) {
globalThis.getComputedStyle = node => {
assert.equal(node, grid);
return {gridTemplateColumns:columns, columnGap:gap};
};
const grid = {};
return grid;
}
test('approved workspace densities share viewport metrics and reject unknown values', async () => {
const {workspaceImageMetrics, layoutImages} = await source;
assert.equal(typeof workspaceImageMetrics, 'function', 'workspace density metrics must be exported');
for (const [width, expected] of [[1600,[3,2,4]], [1250,[2,2,3]], [950,[2,2,3]], [700,[2,2,2]], [390,[2,2,2]], [1700,[4,2,5]]]) {
assert.deepEqual(['comfortable','large','compact'].map(density => workspaceImageMetrics(width,density).columns), expected);
for (const density of ['comfortable','large','compact']) {
const metrics = workspaceImageMetrics(width,density);
assert.deepEqual(metrics, {columns: expected[['comfortable','large','compact'].indexOf(density)], gap:13, footerHeight:54, listHeight:97});
assert.equal(layoutImages([{},{}],990,'list',240,metrics).positions[1].x,0);
}
}
assert.throws(() => workspaceImageMetrics(1600,'unknown'), TypeError);
assert.deepEqual(workspaceImageMetrics(1600),workspaceImageMetrics(1600,'comfortable'));
});
test('approved grid uses inner media at four to three and explicit columns with thirteen pixel gaps', async () => {
const {workspaceImageMetrics,layoutImages} = await source;
assert.equal(typeof workspaceImageMetrics,'function');
const metrics = workspaceImageMetrics(1600);
const {positions} = layoutImages(Array(4).fill({}),990,'grid',240,metrics);
assert.equal(positions[1].x-positions[0].width,13);
assert.equal(positions[3].x,0);
assert.equal(positions[3].y-positions[0].height,13);
assert.equal(positions[0].height-54-2,(positions[0].width-2)*.75);
assert.equal(layoutImages([{}],990,'list',240,metrics).height,97);
const custom=layoutImages([{}],990,'grid',240,{columns:2,gap:20,footerHeight:60,listHeight:100});
assert.equal(custom.positions[0].width,485);
assert.equal(custom.height,(485-2)*.75+62);
assert.equal(layoutImages([{}],990,'list',240,{listHeight:100}).height,100);
test('layout uses columns and gap from computed style', async () => {
const {gridMetrics, layoutImages} = await source;
const original = globalThis.getComputedStyle;
try {
for (const [columns, gap, expected] of [
['329.333px 329.333px 329.333px', '13px', {columns:3, gap:13}],
['200px 200px 200px 200px 200px', '20px', {columns:5, gap:20}],
['990px', '13px', {columns:1, gap:13}],
['none', 'normal', {columns:1, gap:0}],
['', '', {columns:1, gap:0}],
]) assert.deepEqual(gridMetrics(computedGrid(columns, gap)), expected, `${columns} / ${gap}`);
} finally { globalThis.getComputedStyle = original; }
const {positions, columns} = layoutImages(Array(6).fill({}), 990, 'grid', {columns:3, gap:13});
assert.equal(columns, 3);
assert.equal(positions[1].x, positions[0].width + 13);
assert.equal(positions[3].x, 0);
assert.equal(positions[3].y, positions[0].height + 13);
// The concept media is `aspect-ratio: 1.33` inside a 1px card border, under the measured `.media-info`.
assert.equal(positions[0].height, (positions[0].width - 2) / 1.33 + 54 + 2);
const wide = layoutImages(Array(6).fill({}), 990, 'grid', {columns:5, gap:20, footerHeight:60});
assert.equal(wide.positions[0].width, (990 - 80) / 5);
assert.equal(wide.positions[0].height, (wide.positions[0].width - 2) / 1.33 + 62);
assert.equal(layoutImages([{}, {}], 990, 'list', {columns:1, gap:13}).positions[1].x, 0);
assert.equal(layoutImages([{}], 990, 'list', {columns:1, gap:13}).height, 97);
assert.equal(layoutImages([{}], 990, 'list', {columns:1, listHeight:100}).height, 100);
});
test('masonry uses real finite proportions and falls back for invalid dimensions', async () => {
const {layoutImages}=await source;
const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}];
const {positions}=layoutImages(images,990,'masonry',240,{columns:3});
const {positions}=layoutImages(images,990,'masonry',{columns:3});
assert.equal(positions[0].height,(positions[0].width-2)*10+56);
assert.equal(positions[1].height,(positions[1].width-2)/10+56);
assert.equal(positions[2].height,(positions[2].width-2)*.75+56);
for (const position of positions) assert.ok(Number.isFinite(position.height));
});
test('gallery width limits density columns without changing ordinary workspace geometry', async () => {
const {workspaceImageMetrics, layoutImages} = await source;
for (const [viewport, width, density, columns] of [
[1600,990,'comfortable',3], [1600,990,'compact',4],
[951,428,'compact',3], [390,362,'comfortable',2],
[1600,190,'comfortable',1], [1600,190,'compact',1],
[390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1],
test('masonry falls back to the concept ratio when a finite size underflows the proportion', async () => {
const {layoutImages}=await source;
// Both sides are finite and positive, but their ratio underflows to 0, so the proportional height is infinite.
const {positions,height}=layoutImages([{width:Number.MIN_VALUE,height:1e300}],300,'masonry',{columns:1});
assert.equal(positions[0].height,(300-2)/1.33+56);
assert.ok(Number.isFinite(height));
});
test('a narrow gallery caps the concept columns at the smallest readable card', async () => {
const {layoutImages, MINIMUM_CARD_WIDTH} = await source;
// One shared minimum: the page marks `.narrow-cards` below the same width the layout caps columns at.
assert.equal(MINIMUM_CARD_WIDTH, 120);
// At 200 percent zoom the concept's 390px breakpoint still asks for two columns of a half-width grid (D3, D31).
for (const [width, asked, columns] of [
[990,3,3], [990,4,4], [428,3,3], [362,2,2], [190,3,1], [190,4,1], [167,2,1], [69.5,2,1], [0,2,1],
]) {
const metrics = workspaceImageMetrics(viewport,density,width);
assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`);
const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics);
assert.equal(positions[columns].x,0);
assert.ok(positions[columns].y > 0);
const geometry = layoutImages(Array(8).fill({}), width, 'grid', {columns:asked, gap:13});
assert.equal(geometry.columns, columns, `${width}/${asked}`);
assert.equal(geometry.positions[columns].x, 0);
assert.ok(geometry.positions[columns].y > 0);
}
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
});
@@ -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);
});
+76
View File
@@ -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.');
});
+149
View File
@@ -183,3 +183,152 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose();
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
});
test('stream sends category, tags and the rating sort and validates them', async () => {
const {createImageStream}=await source;const calls=[];
const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}});
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'});
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating');
await stream.reset({category:'',tags:[]});
assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false);
for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i);
});
test('positive count comes from the first page, survives plain cursor pages and hides when unknown', async () => {
const {createImageStream}=await source;
const replies=[{...page([image('a')],3,'one'),positive_count:2},page([image('b')],3,'two'),{...page([image('c')],3),positive_count:null}];
const stream=createImageStream({api:{get:async()=>replies.shift()}});
assert.equal(stream.state.positiveCount,null);
await stream.reset();assert.equal(stream.state.positiveCount,2);
await stream.loadMore();assert.equal(stream.state.positiveCount,2);
await stream.loadMore();assert.equal(stream.state.positiveCount,2);
const failed=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count:null})}});
await failed.reset();assert.equal(failed.state.positiveCount,null);
const absent=createImageStream({api:{get:async()=>page([image('a')])}});
await absent.reset();assert.equal(absent.state.positiveCount,null);
for(const positive_count of [-1,'2',1.5]) {
const invalid=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count})}});
await invalid.reset();assert.match(invalid.state.error.message,/page/i);
}
const restored=createImageStream({api:{get(){assert.fail('unexpected request');}},restore:{filters:{},state:{...stream.state}}});
assert.equal(restored.state.positiveCount,2);
assert.throws(()=>createImageStream({api:{get(){}},restore:{filters:{},state:{...stream.state,positiveCount:-3}}}),TypeError);
});
test('the count line reads N of M images and K positive prompts only when known', async () => {
const {countLine}=await source;
assert.equal(countLine({loaded:40,total:102,positiveCount:2}),'40 of 102 images · 2 positive prompts');
assert.equal(countLine({loaded:1,total:1,positiveCount:1}),'1 of 1 image · 1 positive prompt');
assert.equal(countLine({loaded:2,total:2,positiveCount:null}),'2 of 2 images');
assert.equal(countLine({loaded:1200,total:3400,positiveCount:0}),'1,200 of 3,400 images · 0 positive prompts');
});
test('the caption date names the newest result day under Newest first only', async () => {
const {captionDate}=await source;
const now=new Date(2026,9,17,15,0);
const at=(...parts)=>({modified_time:new Date(...parts).getTime()/1000});
assert.equal(captionDate([at(2026,9,17,9),at(2026,9,10)],'newest',now),'Today · October 17');
assert.equal(captionDate([at(2026,9,16,23),at(2026,9,10)],'newest',now),'Yesterday · October 16');
// D17 ruling: only under Newest first; Oldest first hides the date too.
assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'');
assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13');
assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025');
for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
assert.equal(captionDate([],'newest',now),'');
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
});
test('the scope chip summarizes source and folder', async () => {
const {scopeLabel}=await source;
const roots=[{index:0,label:'output'},{index:1,label:'input'}];
assert.equal(scopeLabel({root:'all',subfolder:''},roots),'All output folders');
assert.equal(scopeLabel({root:'1',subfolder:'shared'},roots),'input · shared');
assert.equal(scopeLabel({root:'0',subfolder:''},roots),'output · All output folders');
assert.equal(scopeLabel({root:'all',subfolder:'bulk'},roots),'All sources · bulk');
assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders');
});
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}});
assert.equal(cardTitle(linked('linked','Alpine observatory')),'Alpine observatory');
for (const status of ['ambiguous','unlinked','unavailable','none']) assert.equal(cardTitle(linked(status,'Alpine observatory')),'alpine_00001.png');
assert.equal(cardTitle(linked('linked',null)),'alpine_00001.png');
assert.equal(cardTitle(linked('linked','')),'alpine_00001.png');
assert.equal(cardTitle({filename:'plain.png'}),'plain.png');
assert.equal(cardTitle({filename:'plain.png',linked:null}),'plain.png');
});
test('the card subtitle is W × H · EXT · Seed N with unknown parts omitted (D11)', async () => {
const {cardSubtitle, mediaRatio}=await source;
assert.equal(cardSubtitle({filename:'a.png',width:1536,height:1152,seed:'42001'}),'1536 × 1152 · PNG · Seed 42001');
// The seed is the listing's decimal string: never a Number, so 2^53 + 1 survives.
assert.equal(cardSubtitle({filename:'a.png',width:8,height:8,seed:'9007199254740993'}),'8 × 8 · PNG · Seed 9007199254740993');
assert.equal(cardSubtitle({filename:'clip.webm',width:null,height:null,seed:null}),'WEBM');
assert.equal(cardSubtitle({filename:'clip.mp4',media_type:'video',width:640,height:360}),'640 × 360 · MP4');
assert.equal(cardSubtitle({filename:'noext',width:0,height:10,seed:''}),'');
assert.equal(cardSubtitle({filename:'.hidden',width:10,height:Infinity,seed:42}),'');
assert.equal(cardSubtitle({filename:'trailing.',seed:'7'}),'Seed 7');
assert.equal(mediaRatio({width:1536,height:1152}),'1536/1152');
for (const image of [{}, {width:0,height:5}, {width:5,height:NaN}, {width:Infinity,height:5}]) assert.equal(mediaRatio(image),'4/3');
});
test('updating one item replaces it immutably and later pages append after it', async () => {
const { createImageStream, imageKey } = await source;
const pages = [page([{...image('a'), favorite:false}, {...image('b'), favorite:true}], 3, 'next'), page([image('c')], 3)];
const states = [];
const hints = [];
const stream = createImageStream({api:{get:async () => pages.shift()}, onChange:(s, hint) => { states.push(s); hints.push(hint); }, batchSize:2});
await stream.reset({});
const before = stream.state, notified = states.length;
// Page loads carry no hint; a patch says so, so the page can repaint one card instead of laying out the collection.
assert.ok(hints.every(hint => hint === undefined));
assert.equal(stream.updateItem('0:a', {favorite:true}), true);
const after = stream.state;
assert.equal(states.length, notified + 1); assert.equal(states.at(-1), after);
assert.deepEqual(hints.at(-1), {patch:'0:a'});
assert.notEqual(after.items, before.items); assert.ok(Object.isFrozen(after.items)); assert.ok(Object.isFrozen(after.items[0]));
assert.equal(after.items[0].favorite, true); assert.equal(before.items[0].favorite, false);
assert.equal(after.items[1], before.items[1]);
assert.equal(after.loaded, before.loaded); assert.equal(after.cursor, before.cursor); assert.equal(after.total, before.total);
// Identity fields cannot be patched, and an unknown key changes nothing.
assert.throws(() => stream.updateItem('0:a', {path:'z'}), TypeError);
assert.throws(() => stream.updateItem('0:a', {root_index:1}), TypeError);
assert.equal(stream.updateItem('0:missing', {favorite:true}), false);
assert.equal(stream.state, after); assert.equal(states.length, notified + 1);
await stream.loadMore();
assert.deepEqual(stream.state.items.map(imageKey), ['0:a', '0:b', '0:c']);
assert.equal(stream.state.items[0].favorite, true);
stream.dispose();
assert.equal(stream.updateItem('0:b', {favorite:false}), false);
assert.equal(stream.state.items[1].favorite, true);
});
test('withItem patches a saved browsing state without touching the other items', async () => {
const { withItem } = await source;
const items = Object.freeze([Object.freeze({...image('a'), favorite:false}), Object.freeze({...image('b'), favorite:false})]);
const state = Object.freeze({items, total:2, cursor:null, hasMore:false, loading:false, error:null, loaded:2, positiveCount:null});
const next = withItem(state, '0:b', {favorite:true});
assert.ok(Object.isFrozen(next)); assert.equal(next.items[1].favorite, true); assert.equal(next.items[0], items[0]);
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');
});
+146
View File
@@ -225,6 +225,48 @@ test('record accepts closed reason codes and rejects contradictory ones', () =>
}
});
test('resolution validator accepts closed reason codes and a bounded node record, rejects others', () => {
const toRecord = contract('toInspectionRecord');
for (const reason_code of ['unsupported-node', 'runtime-value', 'composite']) {
for (const node of [{ id: '7', class_type: 'N'.repeat(200) }, { id: '7'.repeat(200), class_type: null }, null]) {
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node });
assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), JSON.stringify({ reason_code, node }));
}
}
for (const change of [
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'N'.repeat(201) } },
{ reason_code: 'unsupported-node', node: { id: '7'.repeat(201), class_type: 'X' } },
{ reason_code: 'unsupported-node', node: { id: '', class_type: 'X' } },
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'X', extra: true } },
{ reason_code: 'unsupported-node', node: [] },
{ reason_code: 'malformed', node: { id: '7', class_type: 'X' } },
{ reason_code: 'work-limit', node: { id: '7', class_type: 'X' } },
{ reason_code: 'no-sampler', node: { id: '7', class_type: 'X' } },
{ reason_code: 'Unsupported-Node', node: null },
{ reason_code: '', node: null },
]) {
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', ...change });
assert.throws(() => toRecord(data, image(), extractGenerationFacts), TypeError, JSON.stringify(change));
}
});
test('role outcome lines are bounded text on non-resolved roles only', () => {
const toRecord = contract('toInspectionRecord');
const line = 'Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
const data = envelope(); data.prompts.negative = { status: 'unresolved', text: null, source: null, message: line };
assert.equal(toRecord(data, image(), extractGenerationFacts).prompts.negative.message, line);
for (const [role, record] of [
['positive', { status: 'resolved', text: 'exact', source: 'execution', message: 'Hidden reason.' }],
['negative', { status: 'unresolved', text: null, source: null, message: '' }],
['negative', { status: 'unresolved', text: null, source: null, message: 7 }],
['negative', { status: 'unresolved', text: null, source: null, message: null }],
['negative', { status: 'unresolved', text: null, source: null, message: 'x'.repeat(513) }],
]) {
const bad = envelope(); bad.prompts[role] = record;
assert.throws(() => toRecord(bad, image(), extractGenerationFacts), TypeError, JSON.stringify(record));
}
});
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
const toRecord = contract('toInspectionRecord');
for (const mutate of [
@@ -422,3 +464,107 @@ test('rooted controller keeps legacy descriptors with ignored noncloneable field
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
});
// The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an
// upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented.
function detailerPipelineGraph() {
return {
1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } },
5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } },
6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } },
7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } },
8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } },
9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } },
10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } },
11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } },
12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } },
13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } },
17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } },
20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } },
25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } },
48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } },
49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } },
};
}
test('workflow prompts and settings match the legacy parser when the server cannot decide', t => {
const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser;
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() });
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' });
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' });
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
assert.equal(record.prompts.negative.text, 'blurry, watermark');
for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) {
assert.equal(record.generation[field].status, 'known', field);
assert.equal(record.generation[field].value, String(legacy[key]), field);
}
});
test('recorded and saved prompts are never replaced by the workflow parse', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
assert.equal(record.prompts.negative.text, 'blurry, watermark');
});
test('a definite server prompt is kept even when the legacy parser reads it differently', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
// The legacy parser follows ConditioningZeroOut back to the positive text node.
const graph = detailerPipelineGraph();
graph[30] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['25', 0] } };
graph[5].inputs.negative = ['30', 0];
data.metadata.prompt_graph = { status: 'parsed', value: graph };
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'resolved', text: '', source: 'zeroed-conditioning' } };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
});
test('callers without the browser global pass the legacy parser explicitly', () => {
const parser = require('../../web/js/comfy-metadata.js');
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const identity = { kind: 'rooted-file', key: '0:x', root_index: 0, path: entry.path, root_scope: scopeA };
const record = contract('projectInspectionPayload')(data, identity, null, extractGenerationFacts, false, parser);
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
assert.equal(record.prompts.negative.text, 'blurry, watermark');
});
test('a setting the file does not record is never filled from the legacy parse', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const extract = input => ({ ...extractGenerationFacts(input), cfg: { status: 'not-recorded', value: null, source: null, evidence: null, reason: null, sampler_id: null } });
const record = contract('toInspectionRecord')(data, entry, extract);
assert.equal(record.generation.cfg.status, 'not-recorded');
assert.equal(record.generation.cfg.value, null);
});
test('a workflow the legacy parser throws on keeps the server result instead of failing the record', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
// The browser parser throws on a null workflow node (it reads properties of it).
data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [null], links: [] } };
data.metadata.raw.workflow = '{"nodes":[null],"links":[]}';
data.metadata.record_formats.workflow = 'reserialized-json';
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
assert.equal(record.prompts.negative.status, 'ambiguous');
});
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
const { libraryTitle } = await import('../../web/js/workspace/library.mjs').catch(() => ({}));
test('library title counts UTF-16 units and never splits a surrogate pair', () => {
assert.equal(typeof libraryTitle, 'function', 'libraryTitle is exported');
assert.equal(libraryTitle(' short line \nsecond'), 'short line');
assert.equal(libraryTitle('a'.repeat(80)), 'a'.repeat(80));
assert.equal(libraryTitle('a'.repeat(81)), `${'a'.repeat(80)}…`);
// 80 emoji are 160 UTF-16 units: 40 fit.
assert.equal(libraryTitle('😀'.repeat(80)), `${'😀'.repeat(40)}…`);
// 79 units then an emoji: the emoji would straddle the limit, so it is dropped whole.
assert.equal(libraryTitle(`${'a'.repeat(79)}😀b`), `${'a'.repeat(79)}…`);
});
+90 -3
View File
@@ -89,7 +89,7 @@ test('occurrence Viewer retains exact caller selection and synchronously closes
const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/);
const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url);
f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2');
f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
f.walk(f.container,n=>n.attributes['aria-label']==='Close image viewer')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count);
view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0);
} finally {view.dispose();parentScope.dispose();f.restore();}
@@ -106,7 +106,7 @@ test('slideshow stops for detail reading nested send close and parent disposal w
const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}};
controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location · identity unverified').length);
find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow'));
find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
find('Play slideshow').fire('click');find('Close image viewer').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0);
} finally {view.dispose();parentScope.dispose();f.restore();}
});
@@ -152,9 +152,96 @@ test('real inspection navigation preserves slideshow across two advances and def
find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click');
assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0);
calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve));
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close image viewer').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live');
view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load');
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
});
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
const instances = [];
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
destroy() { this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [loading] = instances, image = loading.node, removed = [];
image.removeAttribute = key => removed.push(key);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending');
// Viewer.js lists only images that still have a src; the pending view needs it.
assert.deepEqual(removed, [], 'the image keeps its src while the view is pending');
loading.viewed = true; loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
assert.deepEqual(removed, ['src'], 'the src is released after the viewer');
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'never destroyed twice');
view.open({items,selectedKey:'c'.repeat(64)});
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
test('viewer renders the concept shell and toolbar order', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children;
assert.equal(dialog.tagName,'dialog');assert.equal(dialog.id,'viewer-dialog');assert.equal(dialog.attributes['aria-labelledby'],'viewer-title');
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
const [header,layout] = dialog.children;
assert.equal(header.className,'dialog-header');
const [heading,close] = header.children, [title,subtitle] = heading.children;
assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']);
assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']);
assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']);
assert.equal(layout.className,'viewer-layout');
const [left,details] = layout.children;
assert.equal(left.className,'viewer-left');
assert.deepEqual([details.tagName,details.id,details.attributes['aria-label']],['aside','viewer-details','Image metadata']);
const [stage,toolbar] = left.children;
assert.deepEqual([stage.tagName,stage.id,toolbar.tagName,toolbar.id,toolbar.className],['div','viewer-stage','div','viewer-toolbar','viewer-toolbar']);
// #249 D34 #8: the toolbar keeps production's named group (the concept's has no role; adding one is invisible).
assert.deepEqual([toolbar.attributes.role,toolbar.attributes['aria-label']],['group','Media controls']);
// With more than one loaded item, Previous/Next are enabled and not described by the position line.
for (const label of ['Previous media','Next media']) {
const [node] = f.walk(f.container,n => n.attributes['aria-label'] === label);
assert.deepEqual([node.attributes['aria-disabled'],node.attributes['aria-describedby']],['false',undefined],label);
}
// The concept's toolbar order, then production's position status and its two reasons.
assert.deepEqual(toolbar.children.map(node => node.textContent.split(':')[0]),
['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →','1 of 2 loaded','Image transforms are unavailable because Viewer.js could not load.','']);
assert.deepEqual(toolbar.children.slice(9).map(node => [node.tagName,node.attributes.role ?? null]),[['p','status'],['p',null],['p',null]]);
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
assert.equal(toolbar.children[9].textContent,'2 of 2 loaded: a.png');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
const linked = {...occurrenceMedia('1',1,'c'),linked:{status:'linked',title:'Quiet geometry'}};
const unlinked = {...occurrenceMedia('2',0,'d'),linked:{status:'unlinked',title:'Ignored'}};
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items:[linked,unlinked],selectedKey:'c'.repeat(64)});
const title = f.walk(f.container,n=>n.id==='viewer-title')[0];
assert.equal(title.textContent,`Quiet geometry · ${linked.filename}`);
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
const g = mountedDom(), scope = createScope();
const other = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
try {
other.open({items:[linked,unlinked],selectedKey:'d'.repeat(64)});
assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename);
} finally { other.dispose(); scope.dispose(); g.restore(); }
});
+71
View File
@@ -76,3 +76,74 @@ test('positive library deep links preserve the selected identity without duplica
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
});
test('nav counts reject negative, non-integer and missing totals', async () => {
const { summarizeNavCounts } = await source;
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
assert.ok(counts instanceof Map);
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
assert.equal(result.get('images'), null, JSON.stringify(invalid));
assert.equal(result.get('library'), 2);
assert.equal(result.get('tags'), null);
}
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
});
test('nav counts settle independently so a slow images read never delays the others', async () => {
const { loadNavCounts } = await source;
let releaseImages;
const images = new Promise(resolve => { releaseImages = resolve; });
const api = {get: async url => {
if (url.startsWith('/prompt_manager/images')) return images;
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
throw new Error('Unavailable');
}};
const settled = [];
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
releaseImages({success: true, total: 40});
const counts = await done;
assert.deepEqual(settled.at(-1), ['images', 40]);
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
});
test('nav counts issue three bounded requests and drop only the failed count', async () => {
const { loadNavCounts } = await source;
const calls = [];
const signal = new AbortController().signal;
const api = {get: async (url, options) => {
calls.push([url, options.signal]);
if (url.startsWith('/prompt_manager/library')) throw new Error('Unavailable');
if (url.startsWith('/prompt_manager/vocabulary/tags')) return {success: true, total: 5};
return {success: true, total: 12};
}};
const counts = await loadNavCounts(api, signal);
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
const urls = calls.map(([url]) => new URL(url, 'http://host'));
assert.deepEqual(urls.map(url => url.pathname),
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
assert.ok(calls.every(([, used]) => used === signal));
});
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
assert.ok(Object.isFrozen(LIBRARY_WRITE_COUNTS));
const calls = [];
const api = {get: async url => {
calls.push(new URL(url, 'http://host').pathname);
return {success: true, total: url.startsWith('/prompt_manager/library') ? 4 : 9};
}};
const settled = [];
const counts = await loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]), LIBRARY_WRITE_COUNTS);
assert.deepEqual(calls, ['/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
});
+21 -5
View File
@@ -23,7 +23,7 @@ function fixture({ dark = false, preference, fetchImpl, storageDenied = false, p
const response = value => ({ ok: true, json: async () => value });
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
test('system uses host palette over OS and preserves reverse proxy prefix', async () => {
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'dark');
@@ -41,13 +41,14 @@ test('supported light palettes accept string and wrapped values', async () => {
}
});
test('explicit overrides persist and follow restores host choice', async () => {
test('explicit overrides persist and system restores host choice', async () => {
const f = fixture({ preference: 'light' }); await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light');
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
f.theme.setPreference('system'); assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.saved.get('pm_workspace_theme'), 'system');
assert.throws(() => f.theme.setPreference('follow'), /preference/i);
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
f.scope.dispose();
});
@@ -132,7 +133,7 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
let url;
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
assert.equal(theme.preference, 'follow');
assert.equal(theme.preference, 'system');
await theme.ready;
assert.equal(url, '/api/settings/Comfy.ColorPalette');
assert.equal(f.root.dataset.caTheme, 'dark');
@@ -140,3 +141,18 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
assert.equal(theme.preference, 'light');
scope.dispose();
});
test('stored follow migrates to system and system follows the host palette', async () => {
const f = fixture({ preference: 'follow', fetchImpl: async () => response('light') });
assert.equal(f.theme.preference, 'system');
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light');
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
assert.equal(f.root.dataset.caTheme, 'light', 'a known host palette wins over the OS');
f.scope.dispose();
const unknown = fixture({ preference: 'follow', dark: true, fetchImpl: async () => response('custom') });
await unknown.theme.ready;
assert.equal(unknown.theme.preference, 'system');
assert.equal(unknown.root.dataset.caTheme, 'dark', 'an unknown host palette falls back to the OS');
unknown.scope.dispose();
});
+613
View File
@@ -0,0 +1,613 @@
// The shared concept toolbar and dialogs (#249 C1): concept app.js toolbar() a347–360, "Refine this view"
// a1348–1364, "Import & inspect" a1483–1491 (D15) and the "Copy prompt" fallback a1223–1241.
const test = require('node:test');
const assert = require('node:assert/strict');
const toolbarModule = import('../../web/js/workspace/toolbar.mjs');
const dialogsModule = import('../../web/js/workspace/workspace-dialogs.mjs');
function optionsCollection(list) {
const collection = { length: list.length, item: index => list[index] ?? null, [Symbol.iterator]: () => list[Symbol.iterator]() };
list.forEach((option, index) => { collection[index] = option; });
return Object.freeze(collection);
}
/** A minimal DOM: enough element behavior for markup, events, focus and dialogs. */
function fakeDOM() {
const previous = { document: global.document };
let active = null;
class Element extends EventTarget {
constructor(tag) {
super();
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.parentNode = null;
this.className = '';
this.id = '';
this.value = '';
this.disabled = false;
this.open = false;
this.selected = false;
this.selectionStart = 0;
this.selectionEnd = 0;
this._text = '';
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
get isConnected() { return this === body || Boolean(this.parentNode?.isConnected); }
// Like a browser's HTMLOptionsCollection: indexed and iterable, but not an Array (no .some/.map).
get options() { return optionsCollection(this.children.filter(child => child.tagName === 'OPTION')); }
append(...nodes) {
for (const node of nodes) {
node.parentNode?.children.splice(node.parentNode.children.indexOf(node), 1);
node.parentNode = this;
this.children.push(node);
}
}
replaceChildren(...nodes) {
for (const child of this.children) child.parentNode = null;
this.children = [];
this._text = '';
this.append(...nodes);
}
remove() {
if (!this.parentNode) return;
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
focus() { active = this; }
select() { this.selectionStart = 0; this.selectionEnd = this.value.length; }
showModal() { this.open = true; }
close() {
if (!this.open) return;
this.open = false;
// Browsers queue the close event as a later task.
setTimeout(() => this.dispatchEvent(new Event('close')), 0);
}
click() { this.dispatchEvent(new Event('click', { cancelable: true })); }
}
const body = new Element('body');
global.document = {
body,
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createElementNS: (namespace, tag) => new Element(tag),
};
const all = (root, predicate, found = []) => {
if (predicate(root)) found.push(root);
for (const child of root.children) all(child, predicate, found);
return found;
};
return {
body, Element,
all: predicate => all(body, predicate),
find: (root, predicate) => all(root, predicate)[0],
restore() { Object.assign(global, previous); },
};
}
const byId = (dom, id) => dom.all(node => node.id === id)[0];
const tags = node => node.children.map(child => child.tagName.toLowerCase());
const optionPairs = select => [...select.options].map(option => [option.value, option.textContent]);
const modal = dom => byId(dom, 'modal');
const tick = () => new Promise(resolve => setTimeout(resolve, 5));
const coreModule = import('../../web/js/shared/toolkit/core.js');
const button = (root, text) => {
const found = [];
const walk = node => {
if (node.tagName === 'BUTTON' && node.textContent === text) found.push(node);
node.children.forEach(walk);
};
walk(root);
return found[0];
};
test('toolbar emits concept markup and order', 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');
dom.body.append(container);
const changes = [];
const filters = [];
const toolbar = mountToolbar(container, {
scope, categories: ['Portraits', 'Landscapes'],
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
});
const root = container.children[0];
assert.equal(toolbar.element, root);
assert.equal(root.tagName, 'SEARCH');
assert.equal(root.className, 'toolbar');
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
const [label, category, sort, filtersButton] = root.children;
assert.equal(label.className, 'search-field');
assert.equal(label.htmlFor, 'search');
assert.deepEqual(tags(label), ['svg', 'input']);
assert.equal(label.children[0].getAttribute('class'), 'icon');
assert.equal(label.children[0].getAttribute('aria-hidden'), 'true');
const input = label.children[1];
assert.equal(input.id, 'search');
assert.equal(input.type, 'search');
assert.equal(input.placeholder, 'Search prompts, tags, filenames…');
// The listing API rejects search text over 500 characters, as the legacy search capped it.
assert.equal(input.maxLength, 500);
assert.equal(input.getAttribute('aria-label'), 'Search workspace');
assert.equal(input.value, 'castle');
assert.equal(category.id, 'category-filter');
assert.equal(category.getAttribute('aria-label'), 'Filter by category');
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Landscapes', 'Landscapes']]);
assert.equal(category.value, 'Landscapes');
assert.equal(sort.id, 'sort');
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
assert.equal(sort.value, 'name');
// D16: no Save view until saved views are persisted.
assert.equal(button(root, 'Save view'), undefined);
assert.equal(filtersButton.textContent, 'Filters');
assert.equal(filtersButton.type, 'button');
filtersButton.click();
assert.deepEqual(filters, [filtersButton]);
category.value = '';
category.dispatchEvent(new Event('change'));
sort.value = 'rating';
sort.dispatchEvent(new Event('change'));
assert.deepEqual(changes, [
{ q: 'castle', category: '', sort: 'name' },
{ q: 'castle', category: '', sort: 'rating' },
]);
// Categories refresh without dropping a still-listed selection.
toolbar.setCategories(['Archive']);
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Archive', 'Archive']]);
toolbar.setValue({ q: 'moon', category: 'Archive', sort: 'newest' });
assert.deepEqual([input.value, category.value, sort.value], ['moon', 'Archive', 'newest']);
assert.deepEqual(toolbar.value(), { q: 'moon', category: 'Archive', sort: 'newest' });
// A still-listed selection survives a category refresh.
toolbar.setCategories(['Portraits', 'Archive']);
assert.equal(category.value, 'Archive');
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Archive', 'Archive']]);
// A selection that is no longer listed drops to All categories.
toolbar.setCategories(['Portraits']);
assert.equal(category.value, '');
// An unknown sort falls back to the first option instead of an empty select.
toolbar.setValue({ sort: 'bogus' });
assert.equal(sort.value, 'newest');
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
onChange() {}, onFilters() {} }), TypeError);
} finally {
scope.dispose();
dom.restore();
}
});
test('toolbar search uses a debounced onChange that stops with its scope', async () => {
const { mountToolbar, SEARCH_DELAY } = 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 changes = [];
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
const input = container.children[0].children[0].children[1];
assert.equal(input.id, 'search');
for (const text of ['c', 'ca', 'cat']) {
input.value = text;
input.dispatchEvent(new Event('input'));
}
assert.deepEqual(changes, []);
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
assert.deepEqual(changes, [{ q: 'cat', category: '', sort: 'newest' }]);
input.value = 'dog';
input.dispatchEvent(new Event('input'));
scope.dispose();
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
assert.equal(changes.length, 1);
assert.equal(container.children.length, 0);
} finally {
scope.dispose();
dom.restore();
}
});
test('sort lists concept options first', async () => {
const { mountToolbar, orderSorts } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
// D14: the concept's three, in the concept's order, then the page's appended options in their own order.
const given = [['newest', 'Recently added'], ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A'],
['rating', 'Highest rated'], ['name', 'Name A–Z']];
assert.deepEqual(orderSorts(given), [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A']]);
// A page without a concept option keeps the ones it has, concept first.
assert.deepEqual(orderSorts([['oldest', 'Oldest first'], ['newest', 'Recently added']]),
[['newest', 'Recently added'], ['oldest', 'Oldest first']]);
// Matching is by the concept's exact labels (en dash): a variant label is an appended option.
assert.deepEqual(orderSorts([['name', 'Name A-Z'], ['newest', 'Recently added']]),
[['newest', 'Recently added'], ['name', 'Name A-Z']]);
assert.throws(() => orderSorts([]), TypeError);
assert.throws(() => orderSorts([['newest']]), TypeError);
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
onChange() {}, onFilters() {} });
const sort = container.children[0].children[2];
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
assert.equal(sort.value, 'oldest');
} finally {
scope.dispose();
dom.restore();
}
});
const REFINE_FIELDS = [
{ key: 'source', options: [['all', 'All sources'], ['0', 'output'], ['1', 'archive']] },
{ key: 'folder', options: [['', 'All output folders'], ['portraits', 'portraits']] },
{ key: 'media', options: [['all', 'All media'], ['image', 'Images'], ['video', 'Videos']] },
{ key: 'tags' },
];
test('refine dialog round-trips values and clear resets them', async () => {
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
const applied = [];
const cleared = [];
const values = { source: '1', folder: 'portraits', media: 'video', tags: 'cinematic' };
openRefineDialog({ scope, fields: REFINE_FIELDS, values, trigger,
onApply: value => applied.push(value), onClear: value => cleared.push(value) });
const dialog = modal(dom);
assert.equal(dialog.open, true);
assert.equal(byId(dom, 'modal-title').textContent, 'Refine this view');
const body = byId(dom, 'modal-body');
const fields = body.children.filter(child => child.className === 'field');
assert.deepEqual(fields.map(field => field.children[0].textContent), ['Source', 'Folder', 'Media', 'Tags']);
assert.deepEqual(fields.map(field => field.children[0].htmlFor), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
assert.deepEqual(fields.map(field => field.children[1].id), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
const [source, folder, media, tagsInput] = fields.map(field => field.children[1]);
assert.deepEqual(optionPairs(source), REFINE_FIELDS[0].options);
assert.deepEqual([source.value, folder.value, media.value, tagsInput.value], ['1', 'portraits', 'video', 'cinematic']);
assert.equal(tagsInput.placeholder, 'e.g. cinematic');
// D14: no mock-status note, because every field is a real filter.
assert.deepEqual(tags(body), ['div', 'div', 'div', 'div', 'div']);
assert.equal(body.children.some(child => child.tagName === 'P'), false);
const actions = body.children[4];
assert.equal(actions.className, 'dialog-actions');
assert.deepEqual(actions.children.map(child => [child.textContent, child.className]),
[['Clear filters', ''], ['Apply filters', 'ca-primary']]);
// Apply returns the values as edited and leaves the caller's object alone.
media.value = 'image';
tagsInput.value = ' moody ';
button(body, 'Apply filters').click();
assert.deepEqual(applied, [{ source: '1', folder: 'portraits', media: 'image', tags: 'moody' }]);
assert.equal(values.media, 'video');
assert.equal(dialog.open, false);
assert.equal(trigger.isConnected && document.activeElement, trigger);
// Reopening with the applied values shows them again (a round trip).
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value), onClear() {} });
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied[1], applied[0]);
// Clear resets every field to its first option (Tags to empty) and reports that.
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value),
onClear: value => cleared.push(value) });
const reopened = byId(dom, 'modal-body');
button(reopened, 'Clear filters').click();
// The concept's Clear also clears the toolbar category; the page applies `category`.
assert.deepEqual(cleared, [{ source: 'all', folder: '', media: 'all', tags: '', category: '' }]);
assert.equal(applied.length, 2);
assert.equal(modal(dom).open, false);
// A page offers only its own fields; the modal is the one shared shell.
openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, onApply: value => applied.push(value), onClear() {} });
const only = byId(dom, 'modal-body').children.filter(child => child.className === 'field');
assert.deepEqual(only.map(field => field.children[0].textContent), ['Tags']);
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied[2], { tags: '' });
assert.equal(dom.all(node => node.id === 'modal').length, 1);
assert.throws(() => openRefineDialog({ scope, fields: [{ key: 'evidence' }], values: {}, onApply() {}, onClear() {} }), TypeError);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('the Folder list follows the chosen Source', async () => {
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
const fields = [REFINE_FIELDS[0], { key: 'folder', options: optionsFor('all'), optionsFor }, REFINE_FIELDS[2]];
const applied = [];
openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'all' },
onApply: value => applied.push(value), onClear() {} });
const [source, folder] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
.map(field => field.children[1]);
// Opened on a chosen Source: only its folders.
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
assert.equal(folder.value, 'temp-only');
// Another Source drops a folder it does not have.
source.value = '0';
source.dispatchEvent(new Event('change'));
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['portraits', 'portraits']]);
assert.equal(folder.value, '');
folder.value = 'portraits';
// A folder the next Source also has stays chosen.
source.value = 'all';
source.dispatchEvent(new Event('change'));
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits', 'temp-only']);
assert.equal(folder.value, 'portraits');
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied, [{ source: 'all', folder: 'portraits', media: 'all' }]);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('a running rescan marks Rescan busy and ignores a second press', async () => {
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const calls = [];
const handle = openImportDialog({ scope, rescanBusy: true, onInspect: () => calls.push('inspect'),
onRescan: () => calls.push('rescan') });
const body = byId(dom, 'modal-body');
const busy = button(body, 'Rescanning…');
assert.ok(busy, 'the busy Rescan says it is running');
assert.equal(busy.getAttribute('aria-disabled'), 'true');
busy.click();
assert.deepEqual(calls, []);
assert.equal(modal(dom).open, true, 'an ignored press keeps the dialog open');
handle.setRescanBusy(false);
assert.equal(busy.textContent, 'Rescan');
assert.equal(busy.getAttribute('aria-disabled'), null);
busy.click();
assert.deepEqual(calls, ['rescan']);
assert.equal(modal(dom).open, false);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('import dialog lists only real operations', async () => {
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const calls = [];
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
const body = byId(dom, 'modal-body');
assert.deepEqual(tags(body), ['p', 'div']);
assert.equal(body.children[0].className, 'small');
const list = body.children[1];
assert.equal(list.className, 'operation-list');
const rows = list.children.map(row => {
assert.equal(row.className, 'operation');
const [text, actions] = row.children;
assert.deepEqual(tags(text), ['h3', 'p']);
assert.equal(actions.className, 'row-actions');
return [text.children[0].textContent, text.children[1].textContent, actions.children[0].textContent];
});
// D15: no "Import portable recipes" until import exists; Rescan finds added or removed files.
assert.deepEqual(rows, [
['Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector'],
['Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan'],
]);
assert.doesNotMatch(body.textContent, /recipe|portable|sample/i);
button(body, 'Rescan').click();
assert.deepEqual(calls, ['rescan']);
assert.equal(modal(dom).open, false);
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
button(byId(dom, 'modal-body'), 'Open inspector').click();
assert.deepEqual(calls, ['rescan', 'inspect']);
assert.equal(modal(dom).open, false);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('copy fallback selects the text', async () => {
const { openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const text = '<b>a castle</b>, "moonlight" & fog';
openCopyFallback(text, { scope });
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
const body = byId(dom, 'modal-body');
assert.deepEqual(tags(body), ['div', 'p']);
const [field, note] = body.children;
assert.equal(field.className, 'field');
const [label, area] = field.children;
assert.equal(label.textContent, 'Select and copy this text');
assert.equal(label.htmlFor, 'copy-text');
assert.equal(area.tagName, 'TEXTAREA');
assert.equal(area.id, 'copy-text');
assert.equal(area.readOnly, true);
assert.equal(area.value, text);
assert.deepEqual([area.selectionStart, area.selectionEnd], [0, text.length]);
assert.equal(document.activeElement, area);
assert.equal(note.className, 'small');
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs restore focus to the trigger on Escape', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const opens = [
trigger => openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, trigger, onApply() {}, onClear() {} }),
trigger => openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} }),
trigger => openCopyFallback('text', { trigger, scope }),
];
for (const open of opens) {
const trigger = new dom.Element('button');
dom.body.append(trigger);
open(trigger);
const dialog = modal(dom);
assert.equal(dialog.open, true);
assert.equal(dialog.getAttribute('aria-labelledby'), 'modal-title');
const header = dialog.children[0];
assert.equal(header.className, 'dialog-header');
assert.deepEqual(tags(header), ['h2', 'button']);
const close = header.children[1];
assert.equal(close.id, 'modal-close');
assert.equal(close.textContent, '×');
assert.equal(close.getAttribute('aria-label'), 'Close dialog');
assert.notEqual(document.activeElement, trigger);
// Escape fires `cancel` on a modal dialog.
const escape = new Event('cancel', { cancelable: true });
dialog.dispatchEvent(escape);
assert.equal(dialog.open, false);
assert.equal(document.activeElement, trigger);
}
// × closes and restores focus too; a removed trigger leaves focus alone.
const trigger = new dom.Element('button');
dom.body.append(trigger);
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
byId(dom, 'modal-close').click();
assert.equal(modal(dom).open, false);
assert.equal(document.activeElement, trigger);
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
trigger.remove();
const close = byId(dom, 'modal-close');
close.click();
assert.notEqual(document.activeElement, trigger);
// Opening over an open dialog swaps its content and keeps the first trigger; dispose removes the shell.
const first = new dom.Element('button');
dom.body.append(first);
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
openCopyFallback('swap', { scope });
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
assert.equal(document.activeElement, first);
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
closeWorkspaceDialog();
assert.equal(modal(dom), undefined);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('an action may open the next dialog without the late close event closing it', async () => {
const { openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const first = new dom.Element('button');
const second = new dom.Element('button');
dom.body.append(first, second);
openImportDialog({ scope, trigger: first, onInspect() {},
onRescan: () => openCopyFallback('next', { scope, trigger: second }) });
button(byId(dom, 'modal-body'), 'Rescan').click();
await tick();
assert.equal(modal(dom).open, true);
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
assert.equal(document.activeElement, byId(dom, 'copy-text'));
// Its own Escape still restores focus to its own trigger.
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
await tick();
assert.equal(document.activeElement, second);
// Replacing the shell ignores the old shell's late close event too.
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
closeWorkspaceDialog();
openImportDialog({ scope, trigger: second, onInspect() {}, onRescan() {} });
await tick();
assert.equal(modal(dom).open, true);
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
} 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;
const dom = fakeDOM();
try {
assert.throws(() => openImportDialog({ onInspect() {}, onRescan() {} }), /page scope/);
assert.throws(() => openRefineDialog({ fields: [], onApply() {}, onClear() {} }), /page scope/);
assert.throws(() => openCopyFallback('text'), /page scope/);
const scope = createScope();
assert.throws(() => openImportDialog({ scope, onInspect() {} }), TypeError);
assert.throws(() => openRefineDialog({ scope, fields: [], onApply() {} }), TypeError);
assert.equal(modal(dom), undefined);
// Disposing the page scope closes and removes the shell, however often the page opened it.
const trigger = new dom.Element('button');
dom.body.append(trigger);
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
openCopyFallback('again', { scope });
scope.dispose();
assert.equal(modal(dom), undefined);
assert.equal(document.activeElement, trigger);
// An old page's scope never closes the next page's dialog.
const older = createScope();
const newer = createScope();
openImportDialog({ scope: older, onInspect() {}, onRescan() {} });
openCopyFallback('next page', { scope: newer });
older.dispose();
assert.equal(modal(dom).open, true);
newer.dispose();
assert.equal(modal(dom), undefined);
} finally {
closeWorkspaceDialog();
dom.restore();
}
});
test('every route change closes the shared workspace dialog', async () => {
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const source = await readFile(path.join(__dirname, '../../web/js/workspace/index.mjs'), 'utf8');
assert.match(source, /import \{[^}]*\bcloseWorkspaceDialog\b[^}]*\} from '\.\/workspace-dialogs\.mjs';/);
const render = source.slice(source.indexOf('function render(route) {'));
const body = render.slice(0, render.indexOf('viewScope = createScope();'));
assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
});
+88
View File
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { canvasRoot, canvasHref, bindCanvasLink } from '../../web/js/workspace/topbar.mjs';
// The shared mount-aware resolver (#164), as api-client.js implements it.
const resolverAt = pathname => url => {
if (!url.startsWith('/prompt_manager/')) return url;
const match = pathname.match(/^(.*)\/prompt_manager(?:\/|$)/);
return match && match[1] ? match[1] + url : url;
};
function link() {
const node = new EventTarget();
node.href = '';
return node;
}
function click(node, init = {}) {
const event = new Event('click', { cancelable: true });
Object.assign(event, { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...init });
node.dispatchEvent(event);
return event;
}
test('the canvas root is the ComfyUI root under the page mount', () => {
assert.equal(canvasRoot(resolverAt('/prompt_manager/workspace')), '/');
assert.equal(canvasRoot(resolverAt('/proxy/comfy/prompt_manager/workspace')), '/proxy/comfy/');
assert.throws(() => canvasRoot(() => 'https://elsewhere.example/'), /canvas/i);
});
test('the canvas root follows a non-standard proxy mount', () => {
assert.equal(canvasRoot(resolverAt('/ComfyBackendDirect/prompt_manager/workspace')), '/ComfyBackendDirect/');
assert.equal(canvasHref(resolverAt('/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/prompt_manager/workspace'),
'/ComfyBackendDirect/');
});
// A4 review: a resolver failure must never stop the shell from mounting, and the fallback stays on the page's mount.
test('a resolver failure falls back to the page mount and never to another origin', () => {
const warn = console.warn;
const warnings = [];
console.warn = (...args) => warnings.push(args);
try {
const broken = () => { throw new Error('resolver unavailable'); };
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/');
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace/'), '/ComfyBackendDirect/');
assert.equal(canvasHref(broken, '/prompt_manager/workspace'), '/');
assert.equal(canvasHref(() => 'https://elsewhere.example/prompt_manager/', '/a/prompt_manager/workspace'), '/a/');
assert.equal(canvasHref(broken, '//elsewhere.example/prompt_manager/workspace'), '/');
assert.equal(canvasHref(broken, '/workspace'), '/');
assert.equal(canvasHref(broken, undefined), '/');
assert.equal(canvasHref(undefined, '/a/prompt_manager/workspace'), '/a/');
assert.equal(warnings.length, 8);
} finally {
console.warn = warn;
}
});
test('ComfyUI link follows its href unless a resolved pair is selected', () => {
const node = link();
const opened = [];
let pair = null;
const sender = { open: (roles, trigger) => opened.push([roles, trigger]) };
const unbind = bindCanvasLink(node, { getSelectedPair: () => pair, sender, href: '/proxy/' });
assert.equal(node.href, '/proxy/');
assert.equal(click(node).defaultPrevented, false);
pair = { positive: 'a castle', negative: '' };
const event = click(node);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(opened, [[{ positive: 'a castle', negative: '' }, node]]);
// A modified or secondary click keeps the browser's link behavior.
for (const init of [{ metaKey: true }, { ctrlKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
assert.equal(click(node, init).defaultPrevented, false);
}
// One role alone is not a pair.
pair = { positive: 'a castle' };
assert.equal(click(node).defaultPrevented, false);
assert.equal(opened.length, 1);
unbind();
pair = { positive: 'a castle', negative: '' };
assert.equal(click(node).defaultPrevented, false);
assert.equal(opened.length, 1);
});
test('ComfyUI link without a pair source is a plain link', () => {
const node = link();
const unbind = bindCanvasLink(node, { href: '/' });
assert.equal(click(node).defaultPrevented, false);
unbind();
});
+69 -1
View File
@@ -188,6 +188,10 @@ class TestLibraryAPI(ImageAPITestCase):
async def test_maximum_parent_page_uses_bounded_sql_queries_for_previews(self):
for index in range(200):
positive = self._save_prompt(f"Parent {index}", tags=[f"tag {index}"])
if index % 3 == 0:
self.api.db.save_prompt_pair(f"Parent {index}", f"Negative {index}")
if index % 4 == 0:
self.api.db.record_prompt_use(positive, times=index + 1)
make_png(self.output_dir / f"{index}.png")
self.api.db.link_image_to_prompt(
positive, str(self.output_dir / f"{index}.png")
@@ -232,6 +236,32 @@ class TestLibraryAPI(ImageAPITestCase):
for item in result["items"]
)
)
# Usage fields and the variant total ride on the same five SELECTs.
self.assertEqual(result["variant_total"], 67)
self.assertEqual(
result["variant_total"],
sum(item["variant_count"] for item in result["items"]),
)
used = {
int(item["text"].split()[-1]): item
for item in result["items"]
if item["last_used_at"] is not None
}
self.assertEqual(sorted(used), list(range(0, 200, 4)))
self.assertTrue(
all(
item["run_count"] == index + 1
and item["last_used_at"].endswith("+00:00")
for index, item in used.items()
)
)
self.assertTrue(
all(
type(item["run_count"]) is int
for item in result["items"]
if item["last_used_at"] is None
)
)
async def test_long_name_cursor_stays_small_and_preserves_full_sorting(self):
prefix = "猫" * 10_000
@@ -309,6 +339,11 @@ class TestLibraryAPI(ImageAPITestCase):
relative = "nested/猫 % #.png"
pair = self.api.db.save_prompt_pair("Cat", "Noise", title="Pair title")
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
with self.api.db.model.get_connection() as connection:
connection.execute(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
(positive, "Positive title"),
)
path = make_png(self.output_dir / relative)
make_png(self.output_dir / "thumbnails/nested/猫 % #_thumb.png")
image = self.api.db.link_image_to_prompt(positive, str(path))
@@ -343,7 +378,8 @@ class TestLibraryAPI(ImageAPITestCase):
self.assertEqual(result["library"]["metadata_scope"], "pair")
self.assertEqual(result["library"]["pair_id"], pair)
self.assertEqual(result["library"]["positive_id"], positive)
self.assertEqual(result["library"]["title"], "Pair title")
# D10/D20: the linked positive's title; "Pair title" names the variant.
self.assertEqual(result["library"]["title"], "Positive title")
async def test_epoch_scoped_all_library_cursors(self):
from database.execution_history_schema import rotate_history_epoch
@@ -438,6 +474,38 @@ class TestLibraryAPI(ImageAPITestCase):
)
return response, await response.json()
async def test_unconvertible_last_use_does_not_fail_list_detail_or_receipt(self):
positive = self._save_prompt("Cat")
other = self._save_prompt("Dog")
with self.api.db.model.get_connection() as connection:
connection.execute(
"UPDATE prompts SET last_used_at=? WHERE id=?",
("9999-12-31T23:59:59-01:00", positive),
)
connection.execute(
"UPDATE prompts SET last_used_at=? WHERE id=?",
("0001-01-01T00:00:00+01:00", other),
)
response, listed = await self.get()
self.assertEqual(response.status, 200, listed)
self.assertEqual(
[item["last_used_at"] for item in listed["items"]], [None, None]
)
response, detail = await self.get(f"/{positive}")
self.assertEqual(response.status, 200, detail)
self.assertIsNone(detail["positive"]["last_used_at"])
response, receipt = await self.patch_parent(
positive,
{
"expected_epoch": detail["library_epoch"],
"if_match": detail["positive"]["edit_token"],
"metadata": {"title": "Portrait"},
},
)
self.assertEqual(response.status, 200, receipt)
self.assertEqual(receipt["positive"]["title"], "Portrait")
self.assertIsNone(receipt["positive"]["last_used_at"])
async def test_native_parent_patch_strict_validation(self):
from database.execution_history_schema import rotate_history_epoch
+29 -10
View File
@@ -244,10 +244,10 @@ class TestPromptInspector(ImageAPITestCase):
async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self):
path = self.output_dir / "image.png"
for content, expected in (
(b"broken", "unresolved"),
(None, "absent"),
("bad-json", "unresolved"),
for content, expected, message in (
(b"broken", "unresolved", "Metadata in this file could not be read."),
(None, "absent", None),
("bad-json", "unresolved", "Prompt metadata in this file is malformed."),
):
if isinstance(content, bytes):
path.write_bytes(content)
@@ -256,6 +256,7 @@ class TestPromptInspector(ImageAPITestCase):
_, result = await self.inspect()
self.assertEqual(result["prompts"]["positive"]["status"], expected)
self.assertIsNone(result["prompts"]["positive"]["text"])
self.assertEqual(result["prompts"]["negative"].get("message"), message)
async def test_compressed_international_and_post_pixel_metadata(self):
import struct
@@ -773,8 +774,11 @@ class TestPromptInspector(ImageAPITestCase):
self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed")
async def test_inspect_route_reports_no_sampler_instead_of_absent(self):
# f17: a BasicGuider-only prompt graph is a record, never "not recorded".
self.parity_fixture("f17")
# f17 with a sampler class the inspector does not know: a prompt graph
# is a record, never "not recorded".
built = parity.build("f17")
built["prompt"]["13"]["class_type"] = "FutureSampler"
parity.write_png(self.output_dir / "f17.png", built)
response, result = await self.inspect("f17.png")
self.assertEqual(response.status, 200)
self.assertEqual(
@@ -785,17 +789,32 @@ class TestPromptInspector(ImageAPITestCase):
self.assertEqual(result["resolution"]["reason_code"], "no-sampler")
self.assertEqual(
result["resolution"]["reason"],
"No supported sampler pair found (saw SamplerCustomAdvanced)",
"No supported sampler pair found (saw FutureSampler)",
)
self.assertEqual(
result["prompts"]["positive"]["message"], "No supported sampler was found."
)
async def test_inspect_route_exposes_reason_code_and_blocking_node(self):
self.parity_fixture("f06a")
# f06a's encoder is supported since Task 2.4; an unknown encoder is not.
built = parity.build("f06a")
built["prompt"]["6"]["class_type"] = "FutureTextEncode"
parity.write_png(self.output_dir / "f06a.png", built)
_, result = await self.inspect("f06a.png")
resolution = result["resolution"]
self.assertEqual(resolution["state"], "unresolved")
self.assertEqual(resolution["reason_code"], "unsupported-node")
self.assertEqual(set(resolution["node"]), {"id", "class_type"})
self.assertIsInstance(resolution["node"]["class_type"], str)
self.assertEqual(
resolution["node"], {"id": "6", "class_type": "FutureTextEncode"}
)
self.assertEqual(
{role: side.get("message") for role, side in result["prompts"].items()},
dict.fromkeys(
("positive", "negative"),
"Prompt uses a node the inspector does not support "
"(FutureTextEncode).",
),
)
png(self.output_dir / "image.png", prompt="{broken")
_, result = await self.inspect()
self.assertEqual(result["resolution"]["reason_code"], "malformed")
+93
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
"""Card facts from header records: the shared legacy parser, bounded and exact."""
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.card_seed import ( # noqa: E402
MAX_RECORD_BYTES,
card_seed,
header_params,
parsed_positive,
)
from utils.generation_params import ( # noqa: E402
GenerationParamsError,
extract_from_records,
extract_generation_params,
)
from utils.inspection_response import ( # noqa: E402
MAX_PROJECTION_DEPTH,
project_graph,
)
try:
from tests.generation_params_cases import CASES, encoder, ksampler # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES, encoder, ksampler # noqa: E402
def graph(*samplers, extra=None):
nodes = {"6": encoder("a lantern at dusk"), "7": encoder("blurry")}
for index, sampler in enumerate(samplers):
nodes[str(10 + index)] = sampler
nodes.update(extra or {})
return {"prompt": json.dumps(nodes)}
def seeded(seed, **settings):
return ksampler(("6", 0), ("7", 0), seed=seed, **settings)
def nested_workflow(depth=900):
"""A small workflow whose node and link ids nest ``depth`` arrays deep."""
nested = "[" * depth + "1" + "]" * depth
return (
'{"nodes":[{"id":%s,"type":"KSampler","widgets_values":[1]}],'
'"links":[[%s,1,0,2,0]]}' % (nested, nested)
)
def info_seed(info):
return card_seed(header_params(info))
class CardSeed(unittest.TestCase):
def test_card_seed_single_sampler(self):
info = graph(seeded(42001))
self.assertEqual(info_seed(info), "42001")
self.assertEqual(parsed_positive(header_params(info)), "a lantern at dusk")
def test_card_seed_two_different_seeds_follows_shared_parser(self):
# Ruling: no separate heuristic; the card shows what the legacy parser
# (and so the inspector) shows for several samplers.
info = graph(seeded(111), seeded(222))
expected = extract_from_records(info["prompt"], None)["seed"]
self.assertIn(expected, (111, 222))
self.assertEqual(info_seed(info), str(expected))
def test_card_seed_linked_input_follows_shared_parser(self):
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
info = graph(seeded(["20", 0]), extra={"20": primitive})
self.assertEqual(info_seed(info), "555")
dangling = graph(seeded(["99", 0]))
self.assertEqual(
extract_from_records(dangling["prompt"], None)["seed"], "Unknown"
)
self.assertIsNone(info_seed(dangling))
def test_card_seed_advanced_sampler_noise_seed(self):
advanced = seeded(None)
advanced["class_type"] = "KSamplerAdvanced"
del advanced["inputs"]["seed"]
advanced["inputs"]["noise_seed"] = 77
self.assertEqual(info_seed(graph(advanced)), "77")
def test_card_seed_oversized_record_is_none(self):
padding = "x" * (MAX_RECORD_BYTES + 1)
big_prompt = graph(seeded(1), extra={"30": encoder(padding)})
self.assertGreater(len(big_prompt["prompt"]), MAX_RECORD_BYTES)
self.assertIsNone(header_params(big_prompt))
big_workflow = {**graph(seeded(1)), "workflow": json.dumps({"x": padding})}
self.assertIsNone(header_params(big_workflow))
# Bytes, not characters: two-byte text over the budget is oversized.
wide = "é" * (MAX_RECORD_BYTES // 2 + 1)
self.assertLessEqual(len(wide), MAX_RECORD_BYTES)
self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})}))
self.assertEqual(info_seed(graph(seeded(1))), "1")
def test_records_past_the_inspection_projection_bound_give_nothing(self):
# The inspector hands the browser parser only graphs its projection
# parsed; a deeper graph is "limited" there, so the card skips it too.
self.assertEqual(
project_graph(nested_workflow(), "workflow")["status"], "limited"
)
self.assertIsNone(header_params({"workflow": nested_workflow()}))
# A limited workflow beside a parsed prompt leaves the prompt's facts.
both = header_params({**graph(seeded(1)), "workflow": nested_workflow()})
self.assertEqual(card_seed(both), "1")
def test_depth_bound_is_the_inspectors(self):
def deep(levels):
nested = "[" * levels + "1" + "]" * levels
extra = '{"class_type":"Note","inputs":{"v":%s}}' % nested
text = graph(seeded(3))["prompt"]
return {"prompt": text[:-1] + ',"30":' + extra + "}"}
# root object, node object, inputs object: three levels before "v".
inside = MAX_PROJECTION_DEPTH - 3
self.assertEqual(info_seed(deep(inside)), "3")
self.assertIsNone(header_params(deep(inside + 1)))
def test_strict_projection_rules_apply(self):
duplicate = '{"10": {"class_type": "KSampler", "inputs": {}}, "10": {}}'
self.assertEqual(project_graph(duplicate, "prompt")["status"], "malformed")
self.assertIsNone(header_params({"prompt": duplicate}))
def test_record_size_is_utf8_bytes_at_both_bounds(self):
frame = '{"a":"%s"}'
ascii_at = frame % ("x" * (MAX_RECORD_BYTES - 8))
self.assertEqual(len(ascii_at), MAX_RECORD_BYTES)
self.assertIsNotNone(header_params({"prompt": ascii_at}))
four_byte = frame % ("\U0001f600" * ((MAX_RECORD_BYTES - 8) // 4))
self.assertLessEqual(len(four_byte.encode()), MAX_RECORD_BYTES)
self.assertIsNotNone(header_params({"prompt": four_byte}))
padded = four_byte + " " * (MAX_RECORD_BYTES - len(four_byte.encode()) + 1)
self.assertIsNone(header_params({"prompt": padded}))
def test_placeholder_and_non_integer_seeds_are_none(self):
for value in ("randomize", " 12", True, 1.5, 7.0, 1e21, None):
with self.subTest(seed=value):
self.assertIsNone(info_seed(graph(seeded(value))))
self.assertIsNone(card_seed({"seed": "Unknown"}))
self.assertIsNone(card_seed({"seed": 7.0}))
self.assertEqual(info_seed(graph(seeded(-3))), "-3")
def test_digit_string_seed_is_what_the_inspector_reads(self):
# The projection carries big integers as text and keeps text as text;
# the inspector shows a digit string as the seed, and so does the card.
self.assertEqual(info_seed(graph(seeded("123"))), "123")
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
# JSON.parse would round it; the projection carries the record's digits.
case = CASES["seed-beyond-double-precision"]
rounded = extract_from_records(case["prompt"], None)["seed"]
self.assertEqual(rounded, float(4974052037430000001))
self.assertEqual(info_seed({"prompt": case["prompt"]}), "4974052037430000001")
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), str(-(2**60) - 1))
def test_seed_is_always_a_decimal_string(self):
# JSON.parse rounds numbers past 2**53, so every seed travels as text.
for value in (0, 7, 2**53, 2**53 + 1, 2**64, -(2**63)):
with self.subTest(seed=value):
self.assertEqual(card_seed({"seed": value}), str(value))
self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64))
def test_digit_string_seed_is_bounded(self):
# 2**64 has 20 digits; a longer string is no seed, and an unbounded one
# must not reach int() (Python's int max-str-digits raises ValueError).
self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20)
self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20)
self.assertIsNone(card_seed({"seed": "1" * 21}))
self.assertIsNone(card_seed({"seed": "9" * 5000}))
self.assertIsNone(info_seed(graph(seeded("9" * 5000))))
def test_seed_conversion_error_gives_none(self):
class Huge(int):
def __str__(self):
raise ValueError("Exceeds the limit for integer string conversion")
self.assertIsNone(card_seed({"seed": Huge(5)}))
def test_workflow_only_record(self):
case = next(
case
for case in CASES.values()
if case["prompt"] is None
and case["workflow"] is not None
and isinstance(extract_from_records(None, case["workflow"])["seed"], int)
)
expected = extract_from_records(None, case["workflow"])["seed"]
self.assertEqual(info_seed({"workflow": case["workflow"]}), str(expected))
def test_records_the_parser_cannot_use_give_nothing(self):
self.assertIsNone(header_params({}))
self.assertIsNone(header_params(None))
self.assertIsNone(header_params({"parameters": "a cat\nSteps: 20, Seed: 9"}))
self.assertIsNone(header_params({"prompt": b"{}"}))
self.assertIsNone(card_seed(None))
self.assertIsNone(parsed_positive(None))
# The browser parser throws here; the card shows nothing.
thrown = CASES["null-workflow-node"]
self.assertIsNone(header_params(thrown))
unparsable = header_params(CASES["unparsable-records"])
self.assertIsNone(card_seed(unparsable))
self.assertIsNone(parsed_positive(unparsable))
def test_every_shared_case_reads_the_projected_graphs(self):
for name, case in CASES.items():
with self.subTest(case=name):
info = {k: v for k, v in case.items() if v is not None}
params = header_params(info)
graphs = {key: project_graph(text, key) for key, text in info.items()}
values = {
key: graph["value"]
for key, graph in graphs.items()
if graph["status"] == "parsed"
}
if not values:
self.assertIsNone(params)
continue
try:
expected = extract_generation_params(values)
except GenerationParamsError:
self.assertIsNone(params)
continue
self.assertEqual(params, expected)
# Prompt text is the same as from a plain parse of the records.
if len(values) == len(info):
raw = extract_from_records(case["prompt"], case["workflow"])
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
if __name__ == "__main__":
unittest.main()
+198
View File
@@ -1432,3 +1432,201 @@ class TestImagePaging(DatabaseTestCase):
self.assertEqual(len(second), 2)
self.assertNotEqual({img["id"] for img in first}, {img["id"] for img in second})
self.assertEqual(len(self.db.search_images_by_prompt("paged")), 5)
class TestImageLinkSummaries(DatabaseTestCase):
"""Batched card summaries follow get_image_prompt_info's matching rules."""
def _statements(self, call):
statements = []
original = self.db.model.get_connection
def traced():
connection = original()
connection.set_trace_callback(statements.append)
return connection
try:
with patch.object(self.db.model, "get_connection", side_effect=traced):
result = call()
finally:
original().set_trace_callback(None)
selects = [s for s in statements if s.lstrip().upper().startswith("SELECT")]
return result, selects
def test_summary_status_matches_image_prompt_info_for_every_rule(self):
owner = self._save("Owner prompt\nsecond line", category="Cat", rating=4)
rival = self._save("Rival prompt")
exact = os.path.join("out", "one", "same.png")
other = os.path.join("out", "two", "same.png")
unique = os.path.join("out", "three", "unique.png")
shared = os.path.join("out", "shared.png")
self.db.link_image_to_prompt(owner, exact)
self.db.link_image_to_prompt(rival, other)
self.db.link_image_to_prompt(owner, unique)
self.db.link_image_to_prompt(owner, shared)
self.db.link_image_to_prompt(rival, shared)
probes = [
exact,
os.path.join("out", "x", "..", "one", "same.png"),
os.path.join("elsewhere", "same.png"),
os.path.join("elsewhere", "unique.png"),
shared,
"missing.png",
]
for fallback in (True, False):
summaries = self.db.get_image_link_summaries(probes, fallback)
self.assertEqual(set(summaries), set(probes))
for probe in probes:
with self.subTest(probe=probe, fallback=fallback):
info = self.db.get_image_prompt_info(
probe, fallback, report_ambiguity=True
)
summary = summaries[probe]
if info is None:
expected = "none"
elif info.get("association_status") == "ambiguous":
expected = "ambiguous"
else:
expected = "linked"
self.assertEqual(summary["status"], expected)
if expected == "linked":
self.assertEqual(summary["positive_id"], info["prompt_id"])
else:
self.assertEqual(
summary,
{
"status": expected,
"positive_id": None,
"title": None,
"title_source": None,
"category": None,
"rating": None,
},
)
linked = self.db.get_image_link_summaries([unique])[unique]
self.assertEqual(
linked,
{
"status": "linked",
"positive_id": owner,
"title": "Owner prompt",
"title_source": "text",
"category": "Cat",
"rating": 4,
},
)
def test_summary_title_prefers_stored_title_then_first_line(self):
long_line = "word " * 30
titled = self._save("Untitled text")
plain = self._save(f" {long_line}\nsecond")
blank = self._save("Blank title text")
with self.db.model.get_connection() as conn:
conn.executemany(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
[(titled, "Stored title"), (blank, " \u3000 ")],
)
paths = {}
for name, prompt in (("titled", titled), ("plain", plain), ("blank", blank)):
paths[name] = os.path.join("out", f"{name}.png")
self.db.link_image_to_prompt(prompt, paths[name])
summaries = self.db.get_image_link_summaries(list(paths.values()))
sources = {
name: (summaries[path]["title"], summaries[path]["title_source"])
for name, path in paths.items()
}
self.assertEqual(sources["titled"], ("Stored title", "stored"))
# Cut like the Library's title(): 80 characters, then an ellipsis.
self.assertEqual(sources["plain"], (long_line.strip()[:80] + "\u2026", "text"))
self.assertEqual(sources["blank"], ("Blank title text", "text"))
def test_pair_summary_title_is_the_linked_positives_never_the_variant(self):
# D10/D20: a pair's own title names its negative variant.
stored = self.db.save_prompt_pair("Stored positive", "Noise", title="Variant")
plain = self.db.save_prompt_pair("Plain positive\nmore", "Blur", title="Other")
positives = {
name: self.db.get_prompt_pair(pair_id)["positive_prompt_id"]
for name, pair_id in (("stored", stored), ("plain", plain))
}
with self.db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
(positives["stored"], "Positive title"),
)
paths = {}
for name, pair_id in (("stored", stored), ("plain", plain)):
paths[name] = os.path.join("out", f"pair-{name}.png")
image = self.db.link_image_to_prompt(positives[name], paths[name])
self.db.link_image_to_pair(pair_id, image, {"source": "test"})
summaries = self.db.get_image_link_summaries(list(paths.values()))
titles = {
name: (
summaries[path]["positive_id"],
summaries[path]["title"],
summaries[path]["title_source"],
)
for name, path in paths.items()
}
self.assertEqual(
titles["stored"], (positives["stored"], "Positive title", "stored")
)
self.assertEqual(
titles["plain"], (positives["plain"], "Plain positive", "text")
)
def test_display_title_matches_library_title_cut(self):
from database.library import display_title
exact = "x" * 80
self.assertEqual(display_title((), exact), exact)
self.assertEqual(display_title((), exact + "y"), exact + "\u2026")
self.assertEqual(display_title((), " short \nnext"), "short")
self.assertIsNone(display_title((" ",), " \n"))
self.assertEqual(display_title(("Kept ",), "text"), "Kept ")
def test_summary_uses_one_select_per_200_paths(self):
prompt = self._save("Batch")
paths = [os.path.join("out", f"{index}.png") for index in range(201)]
for path in paths[:5]:
self.db.link_image_to_prompt(prompt, path)
result, selects = self._statements(
lambda: self.db.get_image_link_summaries(paths[:200])
)
self.assertEqual(len(selects), 1)
self.assertEqual(len(result), 200)
self.assertEqual(
[result[path]["status"] for path in paths[:6]], ["linked"] * 5 + ["none"]
)
_, selects = self._statements(lambda: self.db.get_image_link_summaries(paths))
self.assertEqual(len(selects), 2)
_, selects = self._statements(lambda: self.db.get_image_link_summaries([]))
self.assertEqual(selects, [])
def test_windows_style_paths_normalize(self):
import ntpath
def windows_normalize(path):
return ntpath.normcase(ntpath.normpath(path))
prompt = self._save("Windows prompt")
stored = r"C:\ComfyUI\Output\Sub\Image.PNG"
with patch("database.operations.normalize_image_path", windows_normalize):
self.db.link_image_to_prompt(prompt, stored)
probes = [
stored,
r"c:\comfyui\output\SUB\image.png",
r"C:\ComfyUI\Output\x\..\Sub\Image.PNG",
"C:/ComfyUI/Output/Sub/Image.PNG",
r"D:\ComfyUI\Output\Sub\Other.png",
]
summaries = self.db.get_image_link_summaries(probes, False)
for probe in probes:
with self.subTest(probe=probe):
info = self.db.get_image_prompt_info(probe, False)
expected = "linked" if info else "none"
self.assertEqual(summaries[probe]["status"], expected)
self.assertEqual(
[summaries[probe]["status"] for probe in probes],
["linked"] * 4 + ["none"],
)
+66
View File
@@ -428,6 +428,72 @@ class TestRestoreFromFile(BackupTestCase):
PromptModel.verify_database_file(dst, require_library_state=True)[0]
)
def test_restore_old_backup_without_favorites(self):
self.db.set_image_favorite("output", "live.png")
src = self._make_source_db(["predates favorites"])
with closing(sqlite3.connect(src)) as conn:
conn.execute("DROP TABLE image_favorites")
conn.commit()
self.assertTrue(PromptModel.verify_database_file(src)[0])
self.db.model.restore_from_file(src)
self.assertEqual(_texts(self.path), ["predates favorites"])
self.assertEqual(self.db.get_image_favorites("output", ["live.png"]), set())
self.assertTrue(self.db.set_image_favorite("output", "new.png"))
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
with closing(sqlite3.connect(src)) as conn: # the source is untouched
self.assertIsNone(
conn.execute(
"SELECT 1 FROM sqlite_master WHERE name='image_favorites'"
).fetchone()
)
def test_restore_preserves_favorites_and_closes_handles(self):
self.db.set_image_favorite("output", "live.png")
with tempfile.TemporaryDirectory() as directory:
src_path = os.path.join(directory, "source.db")
src = PromptDatabase(src_path)
try:
self._save(src, "with favorites")
src.set_image_favorite("output", "sub/kept.png")
src.set_image_favorite("extra/root", "kept.png")
finally:
src.close_all()
# The favorites still sit in the WAL; a backup must carry them.
backup = os.path.join(directory, "backup.db")
reopened = PromptDatabase(src_path)
try:
self.assertTrue(reopened.model.backup_database(backup))
finally:
reopened.close_all()
self.db.model.restore_from_file(backup)
self.assertEqual(
self.db.get_image_favorites("output", ["sub/kept.png", "live.png"]),
{"sub/kept.png"},
)
self.assertEqual(
self.db.get_image_favorites("extra/root", ["kept.png"]), {"kept.png"}
)
PromptModel.close_all_instances()
# Windows refuses to delete a file a restore left open.
assert_closed(self, directory)
backups = list(Path(self.tmpdir).glob("prompts.db.backup_*"))
self.assertEqual(len(backups), 1)
with closing(sqlite3.connect(str(backups[0]))) as conn:
self.assertEqual(
conn.execute(
"SELECT root_key, relative_path FROM image_favorites"
).fetchall(),
[("output", "live.png")],
)
assert_closed(self, str(backups[0]))
def test_restore_without_a_live_database_returns_empty_backup_path(self):
src = self._make_source_db(["fresh"])
self.db.close_all()
+227
View File
@@ -0,0 +1,227 @@
"""Tests for utils.generation_params, the Python port of the browser parser.
Expected values are what web/js/comfy-metadata.js extractGenerationParams
returns for the same input (tests/test_generation_params_js_parity.py checks
that directly when Node is available).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.generation_params import ( # noqa: E402
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
extract_from_records,
parse_js_json,
)
try:
from tests.generation_params_cases import CASES # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES # noqa: E402
def run(name):
return extract_from_records(CASES[name]["prompt"], CASES[name]["workflow"])
class TracesPrompts(unittest.TestCase):
def test_sampler_prompts_and_settings(self):
self.assertEqual(
run("basic"),
{
"positivePrompt": "a cat on a windowsill",
"negativePrompt": "blurry, watermark",
"checkpoint": "example.safetensors",
"steps": 20,
"cfgScale": 7,
"sampler": "euler",
"seed": 5,
},
)
def test_prompt_manager_joins_prepend_and_append_once(self):
result = run("prompt-manager-prepend-append")
self.assertEqual(result["positivePrompt"], "masterpiece a cat 4k")
# The text already starts with the prepend text, so it is not repeated.
self.assertEqual(result["negativePrompt"], "masterpiece, a dog 4k")
def test_linked_text_joins_text_inputs_with_the_delimiter(self):
result = run("linked-text-concat")
self.assertEqual(result["positivePrompt"], "first part, second part")
self.assertEqual(result["negativePrompt"], PLACEHOLDERS["negativePrompt"])
def test_prompt_manager_pair_outputs(self):
result = run("prompt-manager-pair")
self.assertEqual(result["positivePrompt"], "pair positive")
self.assertEqual(result["negativePrompt"], "pair negative")
def test_conditioning_passthrough_follows_the_role(self):
result = run("conditioning-passthrough")
self.assertEqual(result["positivePrompt"], "through a controlnet")
self.assertEqual(result["negativePrompt"], "negative through it")
def test_several_samplers_sharing_one_prompt_pair(self):
result = run("shared-prompt-pipeline")
self.assertEqual(result["positivePrompt"], "a lighthouse at dusk, oil painting")
self.assertEqual(result["negativePrompt"], "blurry, watermark")
self.assertEqual(
(result["seed"], result["steps"], result["sampler"]), (111, 4, "lcm")
)
def test_overwritten_prompt_manager_text_is_recovered_from_widgets(self):
result = run("overwritten-prompt-manager-text")
self.assertEqual(result["positivePrompt"], "the real positive")
self.assertEqual(result["negativePrompt"], "the real negative")
class ChoosesTheSampler(unittest.TestCase):
def test_first_sampler_follows_javascript_key_order(self):
# Integer-like IDs come first in ascending order, then the others.
result = run("first-sampler-in-js-key-order")
self.assertEqual(result["positivePrompt"], "from sampler five")
self.assertEqual(result["seed"], 55)
def test_custom_sampler_reads_its_guider_and_split_settings(self):
result = run("custom-sampler-cfg-guider")
self.assertEqual(result["positivePrompt"], "a red fox in snow")
self.assertEqual(result["negativePrompt"], "lowres")
self.assertEqual(
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
(42, 8, 3.5, "dpmpp_2m"),
)
def test_a_guider_without_prompt_inputs_leaves_prompts_unknown(self):
result = run("custom-sampler-basic-guider")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
# KSamplerSelect still matches the loose /ksampler/ settings lookup.
self.assertEqual(result["sampler"], "dpmpp_2m")
self.assertEqual(result["seed"], PLACEHOLDERS["seed"])
def test_no_sampler_reads_loose_settings(self):
result = run("no-sampler-loose-settings")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
(9, 12, 2, "ddim"),
)
self.assertEqual(result["checkpoint"], "example.safetensors")
def test_linked_seed_prefers_the_input_of_the_same_name(self):
self.assertEqual(run("primitive-seed")["seed"], 77)
class ReadsTheWorkflow(unittest.TestCase):
def test_workflow_only_file(self):
self.assertEqual(
run("workflow-only"),
{
"positivePrompt": "a tidepool at noon",
"negativePrompt": "noisy",
"checkpoint": "example.safetensors",
"steps": 25,
"cfgScale": 6.5,
"sampler": "dpmpp_2m",
"seed": 314,
},
)
class StaysBounded(unittest.TestCase):
def test_cycles_terminate(self):
result = run("conditioning-cycle")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
def test_long_chains_resolve_within_the_visit_limit(self):
self.assertEqual(
run("chain-within-visit-limit")["positivePrompt"], "deep chain text"
)
def test_chains_beyond_the_visit_limit_stop_without_recursion_errors(self):
result = run("chain-beyond-visit-limit")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "deep chain text")
def test_the_visit_limit_boundary_matches_the_browser_parser(self):
# 499 pass-through nodes and the encoder are exactly 500 visits.
self.assertEqual(
run("chain-at-last-visit")["positivePrompt"], "deep chain text"
)
self.assertEqual(
run("chain-one-past-last-visit")["positivePrompt"],
PLACEHOLDERS["positivePrompt"],
)
def test_very_long_chains_do_not_exhaust_the_python_stack(self):
graph = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "t"}}}
for index in range(5000):
graph[str(10 + index)] = {
"class_type": "P",
"inputs": {"conditioning": [str(9 + index if index else 1), 0]},
}
graph["2"] = {
"class_type": "KSampler",
"inputs": {
"positive": ["5009", 0],
"negative": ["1", 0],
"model": ["1", 0],
},
}
result = extract_generation_params({"prompt": graph})
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "t")
class DeeplyNestedValues(unittest.TestCase):
def test_deeply_nested_values_do_not_exhaust_the_python_stack(self):
result = run("deeply-nested-values")
self.assertEqual(result["positivePrompt"], "nested neighbours")
class Placeholders(unittest.TestCase):
def test_blank_text_is_a_placeholder_and_zero_seed_is_kept(self):
result = run("blank-text-and-zero-seed")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "kept negative")
self.assertEqual(result["seed"], 0)
def test_nothing_recorded(self):
self.assertEqual(run("nothing"), dict(PLACEHOLDERS))
self.assertEqual(run("unparsable-records"), dict(PLACEHOLDERS))
class MalformedInput(unittest.TestCase):
def test_a_null_workflow_node_fails_like_the_browser_parser(self):
with self.assertRaises(GenerationParamsError):
run("null-workflow-node")
class ParsesLikeJavaScript(unittest.TestCase):
def test_rejects_non_standard_numbers(self):
for text in ('{"a": NaN}', '{"a": Infinity}', "[-Infinity]", "not json", ""):
with self.subTest(text=text):
self.assertIsNone(parse_js_json(text))
def test_large_integers_round_like_doubles(self):
self.assertEqual(
parse_js_json('{"seed": 9007199254740993}'), {"seed": 9007199254740992.0}
)
self.assertEqual(
parse_js_json('{"seed": 9007199254740991}'), {"seed": 9007199254740991}
)
def test_seeds_beyond_double_precision_round_like_the_browser(self):
self.assertEqual(
run("seed-beyond-double-precision")["seed"], 4974052037430000001.0
)
def test_duplicate_keys_keep_the_last_value(self):
self.assertEqual(parse_js_json('{"a": 1, "a": 2}'), {"a": 2})
if __name__ == "__main__":
unittest.main()
+637
View File
@@ -0,0 +1,637 @@
"""Randomized differential test: the Python parser equals the browser parser.
Generates seeded random API graphs and UI workflows, from realistic to
malformed, and requires utils.generation_params to return exactly what
web/js/comfy-metadata.js extractGenerationParams returns, or to fail where it
throws. Runs only where Node is available. Set PM_PARAMS_FUZZ_CASES to run more.
"""
import json
import os
import random
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
CASE_COUNT = int(os.environ.get("PM_PARAMS_FUZZ_CASES", "3000"))
SEEDS = (20261020, 7, 424242)
NODE_IDS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"9",
"10",
"12",
"25",
"26",
"100",
"1300:5",
"1300:8",
"x",
"05",
"4294967295",
"-1",
"constructor",
]
CLASS_TYPES = [
"KSampler",
"KSamplerAdvanced",
"SamplerCustomAdvanced",
"CFGGuider",
"BasicGuider",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"PromptManagerText",
"StringConcatenate",
"PrimitiveString",
"ControlNetApplyAdvanced",
"ConditioningCombine",
"CheckpointLoaderSimple",
"UNETLoader",
"LoraLoader",
"RandomNoise",
"KSamplerSelect",
"BasicScheduler",
"Reroute",
"MyKSamplerThing",
"SaveImage",
"FaceDetailer",
"toString",
"",
]
INPUT_KEYS = [
"text",
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"seed",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"ckpt_name",
"unet_name",
"model_name",
"prepend_text",
"append_text",
"negative_text",
"delimiter",
"string_a",
"string_b",
"value",
"text_g",
"prompt",
"0",
"10",
"clip",
]
TEXTS = [
"a cat",
" padded text ",
"",
" ",
"bom text ",
"line one\nline two",
"masterpiece",
"masterpiece, a dog",
"4k",
"a dog 4k",
"ünïcödé ✓",
", ",
]
WORKFLOW_TYPES = [
"KSampler",
"KSamplerAdvanced",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"CheckpointLoaderSimple",
"CheckpointLoader",
"UNETLoader",
"MyUnetLoader",
"Reroute",
"GetNode",
"SetNode",
"Note",
"toString",
"valueOf",
]
class Generator:
def __init__(self, seed):
self.random = random.Random(seed)
def pick(self, items):
return self.random.choice(items)
def chance(self, probability):
return self.random.random() < probability
def number(self):
return self.pick(
[
0,
1,
2,
3,
5,
20,
7,
1.0,
1.5,
6.5,
0.45,
1.5e-7,
2.5e21,
-3,
9007199254740991,
]
)
def link(self):
return [
self.pick(NODE_IDS + [3, 12, 5.0]),
self.pick([0, 1, 2, 3, 1.0, 0, 0, 0, -1]),
]
def value(self, key):
roll = self.random.random()
if (
key
in (
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"clip",
)
and roll < 0.8
):
return self.link()
if roll < 0.3:
return self.link()
if roll < 0.6:
return self.pick(TEXTS)
if roll < 0.85:
return self.number()
return self.pick(
[True, False, None, [1, 2, 3], {"nested": "x"}, [], "randomize"]
)
def inputs(self):
if self.chance(0.04):
return self.pick([[], ["text"], "text", 3, None, True])
keys = self.random.sample(INPUT_KEYS, self.random.randint(0, 9))
return {key: self.value(key) for key in keys}
def node(self):
if self.chance(0.03):
return self.pick([None, "node", 5, [1, 2]])
node = {"class_type": self.pick(CLASS_TYPES), "inputs": self.inputs()}
if self.chance(0.05):
del node["class_type"]
return node
def graph(self):
ids = self.random.sample(NODE_IDS, self.random.randint(1, 12))
if self.chance(0.03):
return [self.node() for _ in ids]
return {node_id: self.node() for node_id in ids}
def workflow_node(self, node_id):
if self.chance(0.02):
return self.pick([None, 3, "node"])
node = {
"id": node_id,
"type": self.pick(WORKFLOW_TYPES),
"mode": self.pick([0, 0, 0, 2, 4]),
}
inputs = []
for _ in range(self.random.randint(0, 4)):
item = {
"name": self.pick(INPUT_KEYS),
"link": self.pick([None, 1, 2, 3, 4, 5, 6, 7, 8, "2"]),
"type": self.pick(["CONDITIONING", "MODEL", "CLIP", "*", "STRING"]),
}
if self.chance(0.2):
item["widget"] = {"name": self.pick(INPUT_KEYS)}
inputs.append(item)
node["inputs"] = (
inputs if not self.chance(0.03) else self.pick([{"a": 1}, "abc", None])
)
node["widgets_values"] = [
self.value(self.pick(INPUT_KEYS)) for _ in range(self.random.randint(0, 6))
]
if self.chance(0.1):
node["widgets_values"].insert(1, "randomize")
if self.chance(0.05):
node["widgets_values_named"] = {
"text": self.pick(TEXTS),
"seed": self.number(),
}
return node
def workflow(self):
if self.chance(0.03):
return self.pick([[], "workflow", {"nodes": "x"}, {"links": []}])
ids = self.random.sample(
[1, 2, 3, 4, 5, 6, 7, 8, "9", 10.0], self.random.randint(1, 8)
)
links = []
for link_id in range(1, self.random.randint(1, 9)):
origin, slot = self.pick(ids), self.pick([0, 1, 2])
if self.chance(0.5):
links.append(
[
link_id,
origin,
slot,
self.pick(ids),
0,
self.pick(["CONDITIONING", "MODEL", "*", "STRING"]),
]
)
else:
links.append(
{
"id": link_id,
"origin_id": origin,
"origin_slot": slot,
"type": self.pick(["CONDITIONING", "MODEL", "*"]),
}
)
return {
"nodes": [self.workflow_node(node_id) for node_id in ids],
"links": links,
}
# --- coherent pipelines, then corrupted ------------------------------------
def text_source(self, graph, ids, depth=0):
"""A node id producing text: an encoder, PromptManager, pair or a text chain."""
node_id = ids.pop()
kind = self.pick(
["encoder", "manager", "pair", "concat", "primitive"]
if depth < 2
else ["encoder", "manager"]
)
if kind == "encoder":
text = (
self.pick(TEXTS)
if self.chance(0.7)
else [self.text_source(graph, ids, depth + 1), 0]
)
graph[node_id] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": ["4", 1]},
}
elif kind == "manager":
graph[node_id] = {
"class_type": self.pick(["PromptManager", "PromptManagerText"]),
"inputs": {
"text": self.pick(TEXTS),
"prepend_text": self.pick(TEXTS + ["masterpiece"]),
"append_text": self.pick(TEXTS + ["4k"]),
"clip": ["4", 1],
},
}
elif kind == "pair":
graph[node_id] = {
"class_type": "PromptManagerPair",
"inputs": {
"text": self.pick(TEXTS),
"negative_text": self.pick(TEXTS),
"prepend_text": "",
"append_text": "",
},
}
elif kind == "concat":
graph[node_id] = {
"class_type": "StringConcatenate",
"inputs": {
"string_a": (
[self.text_source(graph, ids, depth + 1), 0] if ids else "x"
),
"string_b": self.pick(TEXTS),
"delimiter": self.pick([", ", " ", 3, "|"]),
},
}
else:
graph[node_id] = {
"class_type": "PrimitiveString",
"inputs": {"value": self.pick(TEXTS)},
}
return node_id
def conditioning(self, graph, ids, role):
source = self.text_source(graph, ids)
slot = (
self.pick([0, 1, 2, 3])
if graph[source]["class_type"] == "PromptManagerPair"
else 0
)
link = [source, slot]
for _ in range(self.pick([0, 0, 1, 2, 3])):
if not ids:
break
node_id = ids.pop()
key = self.pick([role, "conditioning", "conditioning_to", "conditioning_1"])
graph[node_id] = {
"class_type": self.pick(
[
"ControlNetApplyAdvanced",
"ConditioningCombine",
"ConditioningSetArea",
]
),
"inputs": {key: link, "strength": 1.0},
}
link = [node_id, 0]
return link
def pipeline(self):
ids = [str(i) for i in self.random.sample(range(1, 400), 120)] + [
"1300:7",
"x",
"constructor",
]
self.random.shuffle(ids)
graph = {
"4": {
"class_type": self.pick(["CheckpointLoaderSimple", "UNETLoader"]),
"inputs": {
self.pick(
["ckpt_name", "unet_name", "gguf_name"]
): "model.safetensors"
},
}
}
for _ in range(self.pick([1, 1, 2, 3])):
positive, negative = self.conditioning(
graph, ids, "positive"
), self.conditioning(graph, ids, "negative")
settings = {
"seed": self.number() if self.chance(0.8) else [ids.pop(), 0],
"steps": self.number(),
"cfg": self.number(),
"sampler_name": self.pick(["euler", "lcm", "dpmpp_2m"]),
}
if settings["seed"] and isinstance(settings["seed"], list):
graph[settings["seed"][0]] = {
"class_type": "Seed",
"inputs": {"seed": self.number(), "other": "x"},
}
if self.chance(0.25):
guider = ids.pop()
graph[guider] = {
"class_type": "CFGGuider",
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
"cfg": self.number(),
},
}
noise, chooser, sigmas = ids.pop(), ids.pop(), ids.pop()
graph[noise] = {
"class_type": "RandomNoise",
"inputs": {"noise_seed": self.number()},
}
graph[chooser] = {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": "euler"},
}
graph[sigmas] = {
"class_type": "BasicScheduler",
"inputs": {"steps": self.number()},
}
graph[ids.pop()] = {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": [noise, 0],
"guider": [guider, 0],
"sampler": [chooser, 0],
"sigmas": [sigmas, 0],
},
}
else:
graph[ids.pop()] = {
"class_type": self.pick(
["KSampler", "KSamplerAdvanced", "FaceDetailer"]
),
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
**settings,
},
}
for _ in range(self.pick([0, 0, 1, 3])): # corruption
node_id = self.pick(list(graph))
node = graph[node_id]
if (
self.chance(0.5)
and isinstance(node, dict)
and isinstance(node.get("inputs"), dict)
and node["inputs"]
):
key = self.pick(list(graph[node_id]["inputs"]))
graph[node_id]["inputs"][key] = self.value(key)
else:
graph[node_id] = self.node()
return graph
def pipeline_workflow(self):
"""A connected workflow: loader, encoders, Reroute or Set/Get hops, sampler."""
nodes, links = [], []
def link(origin, slot, kind):
links.append(
[len(links) + 1, origin, slot, 0, 0, kind]
if self.chance(0.6)
else {
"id": len(links) + 1,
"origin_id": origin,
"origin_slot": slot,
"type": kind,
}
)
return len(links)
nodes.append(
{
"id": 4,
"type": self.pick(
["CheckpointLoaderSimple", "UNETLoader", "MyUnetLoader"]
),
"mode": 0,
"inputs": [],
"widgets_values": ["model.safetensors"],
}
)
refs = []
for index, role in enumerate(("positive", "negative")):
encoder = 10 + index
nodes.append(
{
"id": encoder,
"type": self.pick(
["CLIPTextEncode", "PromptManager", "PromptManagerText"]
),
"mode": self.pick([0, 0, 0, 4]),
"inputs": [
{"name": "clip", "link": link(4, 1, "CLIP"), "type": "CLIP"}
],
"widgets_values": [
self.pick(TEXTS),
"",
"",
"",
self.pick(TEXTS),
self.pick(TEXTS),
],
}
)
ref = link(encoder, 0, "CONDITIONING")
hop = self.pick(["none", "none", "reroute", "setget"])
if hop == "reroute":
nodes.append(
{
"id": 20 + index,
"type": "Reroute",
"mode": 0,
"inputs": [{"name": "", "link": ref}],
}
)
ref = link(20 + index, 0, "*")
elif hop == "setget":
name = self.pick(["cond_" + role, "shared"])
nodes.append(
{
"id": 30 + index,
"type": "SetNode",
"mode": 0,
"inputs": [{"name": "c", "link": ref}],
"widgets_values": [name],
}
)
nodes.append(
{
"id": 40 + index,
"type": "GetNode",
"mode": 0,
"inputs": [],
"widgets_values": [name],
}
)
ref = link(40 + index, 0, "CONDITIONING")
refs.append(ref)
widgets = [
self.number(),
self.pick(["randomize", "fixed", "x"]),
self.number(),
self.number(),
"euler",
"normal",
1,
]
nodes.append(
{
"id": 3,
"type": self.pick(["KSampler", "KSamplerAdvanced"]),
"mode": 0,
"inputs": [
{"name": "model", "link": link(4, 0, "MODEL"), "type": "MODEL"},
{"name": "positive", "link": refs[0], "type": "CONDITIONING"},
{"name": "negative", "link": refs[1], "type": "CONDITIONING"},
],
"widgets_values": widgets,
}
)
if self.chance(0.3):
nodes[self.random.randrange(len(nodes))] = self.workflow_node(
self.pick([1, 2, 99])
)
return {"nodes": nodes, "links": links}
def case(self):
roll = self.random.random()
structured = self.chance(0.7)
prompt = (
json.dumps(self.pipeline() if structured else self.graph())
if roll < 0.75
else None
)
workflow = (
json.dumps(self.pipeline_workflow() if structured else self.workflow())
if roll > 0.45
else None
)
if self.chance(0.02):
prompt = self.pick(['{"1": NaN}', "{", "null", "[]", '"text"', "12"])
return {"prompt": prompt, "workflow": workflow}
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
class RandomGraphs(unittest.TestCase):
def test_random_graphs_match_the_browser_parser(self):
for seed in SEEDS:
generator = Generator(seed)
cases = [generator.case() for _ in range(CASE_COUNT)]
expected = run_oracle(cases)
self.assertEqual(len(expected), len(cases))
mismatches = []
for index, (case, result) in enumerate(zip(cases, expected)):
actual = run_python(case)
if not js_equal(actual, result):
mismatches.append((index, case, actual, result))
with self.subTest(seed=seed):
outcomes = {
"ok": sum(r["ok"] for r in expected),
"throws": sum(not r["ok"] for r in expected),
}
self.assertEqual(
mismatches[:3],
[],
f"{len(mismatches)} of {len(cases)} differ; outcomes {outcomes}",
)
if __name__ == "__main__":
unittest.main()
+155
View File
@@ -0,0 +1,155 @@
"""The Python generation-parameter parser equals the browser parser.
Golden vectors (tests/fixtures/generation_params/vectors.json) hold the browser
parser's results for every named case and every PNG fixture, so this runs on
machines without Node. With Node, the live browser parser is compared too and
the vectors are checked for staleness. Regenerate them with:
python tests/test_generation_params_js_parity.py --write-vectors
"""
import json
import math
import shutil
import subprocess # nosec B404 - runs the repository's own Node oracle
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from utils.generation_params import ( # noqa: E402
GenerationParamsError,
extract_from_records,
)
try:
from tests.generation_params_cases import CASES # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES # noqa: E402
VECTORS = ROOT / "tests" / "fixtures" / "generation_params" / "vectors.json"
ORACLE = ROOT / "tests" / "js" / "support" / "generation-params-oracle.mjs"
FIXTURE_DIRS = [ROOT / "tests" / "fixtures"]
NODE = shutil.which("node")
def fixture_cases():
"""Text records of every PNG fixture, keyed by its repository path."""
from PIL import Image
cases = {}
for directory in FIXTURE_DIRS:
for path in sorted(directory.rglob("*.png")):
with Image.open(path) as image:
info = {k: v for k, v in image.info.items() if isinstance(v, str)}
if "prompt" in info or "workflow" in info:
name = path.relative_to(ROOT).as_posix()
cases[name] = {
"prompt": info.get("prompt"),
"workflow": info.get("workflow"),
}
return cases
def all_cases():
return {**{f"case:{k}": v for k, v in CASES.items()}, **fixture_cases()}
def run_oracle(cases):
"""Browser parser results for case records, in order."""
lines = "".join(json.dumps(case) + "\n" for case in cases)
completed = subprocess.run( # nosec B603 - fixed argv, no shell
[NODE, str(ORACLE)],
input=lines,
capture_output=True,
text=True,
encoding="utf-8",
check=True,
timeout=600,
)
return [json.loads(line) for line in completed.stdout.splitlines()]
def run_python(case):
try:
return {
"ok": True,
"result": extract_from_records(case["prompt"], case["workflow"]),
}
except GenerationParamsError:
return {"ok": False}
def js_equal(left, right):
"""Equality as JavaScript values: 1 == 1.0, but true is not 1."""
if isinstance(left, bool) or isinstance(right, bool):
return isinstance(left, bool) and isinstance(right, bool) and left == right
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
# Every JavaScript number is a double; compare as doubles.
left, right = float(left), float(right)
return left == right or (math.isnan(left) and math.isnan(right))
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(
js_equal(a, b) for a, b in zip(left, right)
)
if isinstance(left, dict) and isinstance(right, dict):
return left.keys() == right.keys() and all(
js_equal(left[k], right[k]) for k in left
)
return type(left) is type(right) and left == right
def write_vectors():
cases = all_cases()
results = run_oracle(cases.values())
vectors = [
{"name": name, **case, "expected": result}
for (name, case), result in zip(cases.items(), results)
]
VECTORS.parent.mkdir(parents=True, exist_ok=True)
VECTORS.write_text(json.dumps(vectors, indent=1, sort_keys=True) + "\n", "utf-8")
print(f"wrote {len(vectors)} vectors to {VECTORS.relative_to(ROOT)}")
class GoldenVectors(unittest.TestCase):
def test_every_vector_matches(self):
vectors = json.loads(VECTORS.read_text("utf-8"))
self.assertGreater(len(vectors), len(CASES))
for vector in vectors:
with self.subTest(vector=vector["name"]):
actual = run_python(vector)
self.assertTrue(
js_equal(actual, vector["expected"]), (actual, vector["expected"])
)
def test_vectors_cover_every_case_and_fixture(self):
names = {vector["name"] for vector in json.loads(VECTORS.read_text("utf-8"))}
self.assertEqual(names, set(all_cases()))
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
class LiveBrowserParser(unittest.TestCase):
def test_live_results_match_python_and_the_vectors(self):
cases = all_cases()
stored = {
vector["name"]: vector["expected"]
for vector in json.loads(VECTORS.read_text("utf-8"))
}
for (name, case), expected in zip(cases.items(), run_oracle(cases.values())):
with self.subTest(case=name):
self.assertTrue(
js_equal(run_python(case), expected), (run_python(case), expected)
)
self.assertTrue(
js_equal(stored[name], expected),
"vectors are stale: regenerate them",
)
if __name__ == "__main__":
if "--write-vectors" in sys.argv:
write_vectors()
else:
unittest.main()
+40
View File
@@ -449,6 +449,46 @@ class CurrentLocationTests(unittest.TestCase):
)
self.assertEqual(reads, [], "Unsupported media must not reach the parser")
def test_preview_files_in_the_host_temp_folder_keep_preview_only_stages(self):
# Saved raw image plus a FaceDetailer that only previews (Codex P2).
import json
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests import inspection_parity as parity
service = self.module("py.api.inspection_service")
graph = parity.build("f19")["prompt"]
graph["9"]["class_type"] = "PreviewImage"
graph["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
info = PngInfo()
info.add_text("prompt", json.dumps(graph))
observed = {}
for name, folder in (("output", self.output), ("temp", self.temp)):
path = folder / "nested" / "same.png"
path.parent.mkdir()
with Image.new("RGB", (4, 4)) as image:
image.save(path, pnginfo=info)
observed[name] = service.inspect_current_file(
path, source_label=name, saved=None, library={"status": "none"}
)
saved = observed["output"]["prompts"]
self.assertEqual(saved["positive"]["status"], "resolved")
self.assertEqual(saved["positive"]["text"], parity.POS)
self.assertEqual(observed["output"]["resolution"]["sampler_id"], "3")
preview = observed["temp"]["prompts"]
self.assertEqual(preview["positive"]["status"], "ambiguous")
self.assertIsNone(preview["positive"]["text"])
# An unknown host temp folder treats the file as saved output.
self.host_temp = None
again = service.inspect_current_file(
self.output / "nested" / "same.png",
source_label="output",
saved=None,
library={"status": "none"},
)
self.assertEqual(again["prompts"]["positive"]["status"], "resolved")
class HistoryImportTests(unittest.TestCase):
def test_api_import_does_not_require_a_running_host_server(self):
+383
View File
@@ -0,0 +1,383 @@
"""Persisted image favorites (spec D7): store, schema and rooted API boundary."""
import json
import ntpath
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
try:
from test_api_images import ImageAPITestCase, make_png
from open_handles import assert_closed
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
from tests.open_handles import assert_closed
from database.image_favorites import (
MAX_KEY_LENGTH,
TABLE,
image_favorites_schema_error,
migrate_image_favorites,
)
from database.models import PromptModel
from database.operations import PromptDatabase
FAVORITE = "/prompt_manager/images/favorite"
class TestImageFavoriteApi(ImageAPITestCase):
async def tearDownAsync(self):
self.api.db.close_all()
assert_closed(self, self._temp_db.name, self._output_tmp.name)
await super().tearDownAsync()
async def send(self, method, body):
response = await self.client.request(method, FAVORITE, json=body)
return response, await response.json()
async def workspace(self, **params):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace", **params}
)
self.assertEqual(response.status, 200)
data = await response.json()
return {image["path"]: image for image in data["images"]}
async def test_favorite_round_trip_and_idempotent(self):
make_png(self.output_dir / "sub" / "a.png")
make_png(self.output_dir / "b.png")
items = await self.workspace()
self.assertIs(items["sub/a.png"]["favorite"], False)
self.assertIs(items["b.png"]["favorite"], False)
body = {"root_index": 0, "path": "sub/a.png"}
for _ in range(2): # a repeated PUT is a no-op, not an error
response, data = await self.send("PUT", body)
self.assertEqual(response.status, 200)
self.assertEqual(
data,
{
"success": True,
"favorite": True,
"root_index": 0,
"path": "sub/a.png",
},
)
items = await self.workspace()
self.assertIs(items["sub/a.png"]["favorite"], True)
self.assertIs(items["b.png"]["favorite"], False)
count = self.api.db.model.get_connection().execute(
f"SELECT COUNT(*) FROM {TABLE}"
)
self.assertEqual(count.fetchone()[0], 1)
for _ in range(2):
response, data = await self.send("DELETE", body)
self.assertEqual(response.status, 200)
self.assertTrue(data["success"])
self.assertIs(data["favorite"], False)
items = await self.workspace()
self.assertIs(items["sub/a.png"]["favorite"], False)
def rows(self):
return [
tuple(row)
for row in self.api.db.model.get_connection().execute(
f"SELECT root_key, relative_path FROM {TABLE} ORDER BY 1, 2"
)
]
async def test_favorite_is_keyed_by_root_not_index(self):
second = self.comfy_dir / "second"
make_png(second / "a.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
response, data = await self.send("PUT", {"root_index": 1, "path": "a.png"})
self.assertEqual(response.status, 200)
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
root_key = Path(os.path.normcase(os.path.realpath(second))).as_posix()
self.assertEqual(self.rows(), [(root_key, "a.png")])
# The same root at another index keeps its favorite; the other root
# with the same relative path does not inherit it.
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
self.api.invalidate_gallery_cache()
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
async def test_roots_sharing_a_folder_name_keep_separate_favorites(self):
# Two roots under different parents, both shown as "output".
with tempfile.TemporaryDirectory() as other_parent:
other = Path(os.path.realpath(other_parent)) / "output"
make_png(other / "a.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace"}
)
labels = {
item["root_index"]: item["root_dir"]
for item in (await response.json())["images"]
}
self.assertEqual(labels, {0: "output", 1: "output"})
await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
await self.send("PUT", {"root_index": 1, "path": "a.png"})
response, data = await self.send(
"DELETE", {"root_index": 0, "path": "a.png"}
)
self.assertEqual(response.status, 200)
self.assertNotIn(other_parent, json.dumps(data))
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], False)
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], True)
listing = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace"}
)
self.assertNotIn(other_parent, await listing.text())
async def test_windows_case_only_rename_keeps_the_star(self):
make_png(self.output_dir / "Sub" / "A.png")
with patch("py.api.images._path_flavor", ntpath):
response, data = await self.send(
"PUT", {"root_index": 0, "path": "Sub/A.png"}
)
self.assertEqual(response.status, 200)
self.assertEqual(data["path"], "Sub/A.png") # the listing's spelling
(self.output_dir / "Sub" / "A.png").rename(
self.output_dir / "Sub" / "a.png"
)
(self.output_dir / "Sub").rename(self.output_dir / "sub")
self.api.invalidate_gallery_cache()
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], True)
response, data = await self.send(
"DELETE", {"root_index": 0, "path": "SUB/A.PNG"}
)
self.assertEqual(response.status, 200)
self.assertEqual(data["path"], "sub/a.png")
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], False)
self.assertEqual(self.rows(), [])
async def test_posix_case_distinct_files_keep_independent_stars(self):
make_png(self.output_dir / "A.png")
make_png(self.output_dir / "a.png")
if len(list(self.output_dir.iterdir())) != 2:
self.skipTest("case-insensitive filesystem")
await self.send("PUT", {"root_index": 0, "path": "A.png"})
items = await self.workspace()
self.assertIs(items["A.png"]["favorite"], True)
self.assertIs(items["a.png"]["favorite"], False)
async def test_favorite_rejects_traversal_and_unknown_root(self):
make_png(self.output_dir / "a.png")
make_png(self.comfy_dir / "outside.png")
(self.output_dir / "notes.txt").write_text("not media")
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
cases = [
({"root_index": 0, "path": "../outside.png"}, 403),
({"root_index": 0, "path": "sub/../../outside.png"}, 403),
({"root_index": 0, "path": str(self.comfy_dir / "outside.png")}, 403),
({"root_index": 0, "path": "/a.png"}, 403),
({"root_index": 0, "path": "C:/a.png"}, 403),
({"root_index": 0, "path": "a.png\0.png"}, 403),
({"root_index": 0, "path": "notes.txt"}, 403),
({"root_index": 1, "path": "a.png"}, 400),
({"root_index": -1, "path": "a.png"}, 400),
({"root_index": "0", "path": "a.png"}, 400),
({"root_index": True, "path": "a.png"}, 400),
({"root_index": 0}, 400),
({"root_index": 0, "path": ""}, 400),
({"root_index": 0, "path": 7}, 400),
({"root_index": 0, "path": "x" * 5000 + ".png"}, 400),
({"root_index": 0, "path": "missing.png"}, 404),
({"root_index": 0, "path": "thumbnails/a_thumb.png"}, 404),
]
for method in ("PUT", "DELETE"):
for body, status in cases:
if method == "DELETE" and status == 404:
continue # unlisting a stale favorite is allowed (below)
with self.subTest(method=method, body=str(body)[:60]):
response, data = await self.send(method, body)
self.assertEqual(response.status, status)
self.assertIs(data["success"], False)
self.assertNotIn(str(self.comfy_dir), data["error"])
response = await self.client.put(FAVORITE, data=b"[1]")
self.assertEqual(response.status, 400)
response = await self.client.put(FAVORITE, data=b"{not json")
self.assertEqual(response.status, 400)
count = self.api.db.model.get_connection().execute(
f"SELECT COUNT(*) FROM {TABLE}"
)
self.assertEqual(count.fetchone()[0], 0)
async def test_delete_clears_a_favorite_whose_file_is_gone(self):
target = make_png(self.output_dir / "gone.png")
await self.send("PUT", {"root_index": 0, "path": "gone.png"})
target.unlink()
self.api.invalidate_gallery_cache()
response, data = await self.send(
"DELETE", {"root_index": 0, "path": "gone.png"}
)
self.assertEqual(response.status, 200)
self.assertIs(data["favorite"], False)
count = self.api.db.model.get_connection().execute(
f"SELECT COUNT(*) FROM {TABLE}"
)
self.assertEqual(count.fetchone()[0], 0)
async def test_legacy_listing_is_unchanged_by_favorites(self):
make_png(self.output_dir / "a.png")
await self.send("PUT", {"root_index": 0, "path": "a.png"})
response = await self.client.get("/prompt_manager/images/output")
data = await response.json()
self.assertEqual(len(data["images"]), 1)
self.assertNotIn("favorite", data["images"][0])
async def test_toggle_lookup_is_built_once_per_scan(self):
make_png(self.output_dir / "a.png")
files = await self.api._get_gallery_files(self.output_dir)
first = self.api._favorite_listing(self.output_dir, files)
self.assertEqual(first, {"a.png": "a.png"})
self.assertIs(self.api._favorite_listing(self.output_dir, files), first)
make_png(self.output_dir / "b.png")
self.api.invalidate_gallery_cache()
rescanned = await self.api._get_gallery_files(self.output_dir)
self.assertEqual(
self.api._favorite_listing(self.output_dir, rescanned),
{"a.png": "a.png", "b.png": "b.png"},
)
self.api.reset_gallery_roots_cache()
self.assertEqual(self.api._favorite_listing_cache, {})
async def test_monitor_invalidation_drops_the_favorites_lookup(self):
make_png(self.output_dir / "a.png")
await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertNotEqual(self.api._favorite_listing_cache, {})
# The image monitor's invalidation must not keep the old scan alive.
self.api.invalidate_gallery_cache()
self.assertEqual(self.api._favorite_listing_cache, {})
self.assertIs((await self.workspace())["a.png"]["favorite"], True)
async def test_put_rejects_a_key_beyond_the_stored_bound(self):
make_png(self.output_dir / "a.png")
too_long = "/" + "r" * MAX_KEY_LENGTH
with patch("py.api.images._favorite_root_key", return_value=too_long):
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertEqual(response.status, 403)
self.assertFalse(data["success"])
self.assertNotIn(too_long, json.dumps(data))
self.assertEqual(self.rows(), [])
# The live database still validates (a stored over-long key would
# fail startup and restore).
conn = self.api.db.model.get_connection()
self.assertIsNone(image_favorites_schema_error(conn))
async def test_favorite_store_failure_keeps_the_listing(self):
make_png(self.output_dir / "a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
self.api.db.get_image_favorites = broken
items = await self.workspace()
self.assertIs(items["a.png"]["favorite"], False)
self.api.db.set_image_favorite = broken
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertEqual(response.status, 500)
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
class TestImageFavoriteSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.path = os.path.join(self.tmpdir, "favorites.db")
def tearDown(self):
PromptModel.close_all_instances()
assert_closed(self, self.tmpdir)
for name in os.listdir(self.tmpdir):
os.unlink(os.path.join(self.tmpdir, name))
os.rmdir(self.tmpdir)
def test_store_round_trip_and_bounded_lookup(self):
db = PromptDatabase(self.path)
try:
self.assertTrue(db.set_image_favorite("output", "a.png"))
self.assertTrue(db.set_image_favorite("output", "a.png"))
db.set_image_favorite("other", "a.png")
self.assertEqual(
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
)
self.assertEqual(db.get_image_favorites("output", []), set())
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
self.assertTrue(db.clear_image_favorite("output", "a.png"))
self.assertFalse(db.clear_image_favorite("output", "a.png"))
self.assertEqual(db.get_image_favorites("output", ["a.png"]), set())
created = db.model.get_connection().execute(
f"SELECT created_at FROM {TABLE}"
)
self.assertRegex(created.fetchone()[0], r"^\d{4}-\d\d-\d\dT")
finally:
db.close_all()
def test_schema_rejects_unsupported_definitions_and_values(self):
variants = {
"missing": None,
"wrong table": f"CREATE TABLE {TABLE} (x TEXT)",
"view": f"CREATE VIEW {TABLE} AS SELECT 1",
"nullable key": (
f"CREATE TABLE {TABLE} (root_key TEXT, relative_path TEXT, "
"created_at TEXT, PRIMARY KEY(root_key, relative_path))"
),
}
for label, ddl in variants.items():
with self.subTest(label):
with closing(sqlite3.connect(":memory:")) as conn:
if ddl:
conn.execute(ddl)
error = image_favorites_schema_error(conn)
if ddl is None:
self.assertIsNone(error)
self.assertIsNotNone(
image_favorites_schema_error(conn, allow_missing=False)
)
else:
self.assertIsNotNone(error)
with self.assertRaises(sqlite3.DatabaseError):
migrate_image_favorites(conn)
with closing(sqlite3.connect(":memory:")) as conn:
migrate_image_favorites(conn)
migrate_image_favorites(conn) # idempotent
self.assertIsNone(image_favorites_schema_error(conn, allow_missing=False))
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', 'now')")
self.assertIsNone(image_favorites_schema_error(conn))
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', x'00ff', 'now')")
self.assertIsNotNone(image_favorites_schema_error(conn))
conn.execute(f"DELETE FROM {TABLE} WHERE typeof(relative_path)='blob'")
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', ?, 'now')", ("x" * 5000,))
self.assertIsNotNone(image_favorites_schema_error(conn))
conn.execute(f"DELETE FROM {TABLE}")
conn.execute(f"CREATE INDEX extra ON {TABLE}(created_at)")
self.assertIsNotNone(image_favorites_schema_error(conn))
def test_verify_rejects_a_backup_with_an_invalid_favorites_table(self):
db = PromptDatabase(self.path)
db.close_all()
PromptModel.close_all_instances()
with closing(sqlite3.connect(self.path)) as conn:
conn.execute(f"DROP TABLE {TABLE}")
conn.execute(f"CREATE TABLE {TABLE} (anything TEXT)")
conn.commit()
ok, reason = PromptModel.verify_database_file(self.path)
self.assertFalse(ok)
self.assertIn("favorites", reason.lower())
if __name__ == "__main__":
unittest.main()
+11 -3
View File
@@ -8,6 +8,7 @@ leaves it unresolved, while the inspection profile resolves it.
import logging
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from utils import prompt_pair_resolver as resolver
@@ -72,12 +73,19 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
mixin.db = Mock()
mixin.db.get_prompt_by_hash.return_value = None
mixin.db.save_prompt.return_value = 7
spy = _Spy()
with patch.object(admin, "resolve_prompt_pair", spy):
status = mixin._ingest_scanned_file("/out/f03.png", _f03_metadata())
self.assertEqual(status, "unresolved")
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
spy.assert_default_and_gated(self)
mixin.db.assert_not_called()
# The resolver leaves f03 unresolved; the scan links the prompt the
# browser parser traces, as the inspector shows it.
self.assertEqual(status, "added")
self.assertEqual(
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
)
mixin.db.link_image_to_prompt.assert_called_once()
if __name__ == "__main__":
+943
View File
@@ -0,0 +1,943 @@
"""Legacy parser rules ported into the inspection resolver (#240, Task 2.7b).
Every graph here is synthetic (neutral texts, generic names). Each class names
the rule from the parser reconciliation (R1, R3, ...) it ports. The default
(ingestion) profile must stay exactly as it was, so each rule also checks that
``DEFAULT_PROFILE`` ignores it.
"""
import io
import json
import tempfile
import unittest
import unittest.mock
import zlib
from pathlib import Path
from PIL import Image
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import (
DUPLICATE_ROLE_BYTES,
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
from tests import inspection_parity as parity
except ImportError: # discovered with ``-s tests``
import inspection_parity as parity
def _inspect(metadata: dict, **options) -> dict:
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE, **options)
def _texts(result: dict) -> tuple:
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
def _pm_encoder(text: str) -> dict:
"""A PromptManager node whose API class was rewritten to CLIPTextEncode."""
return {
"class_type": "CLIPTextEncode",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"clip": ["4", 1],
},
}
def _overwritten(widgets=("a cat", "a dog", "a bird"), api_text="a bird") -> dict:
"""Pre-3.2.3 shape: three PromptManager nodes, every API ``text`` overwritten.
KSampler 3 reads node 17 (widget A) for its positive; the saved API text of
17, 113 and 119 is the last-run node's prompt (C) for all three.
"""
prompt = parity.build("f02")["prompt"]
prompt["3"]["inputs"]["positive"] = ["17", 0]
del prompt["6"]
for node_id in ("17", "113", "119"):
prompt[node_id] = _pm_encoder(api_text)
workflow = {
"nodes": [
{"id": int(node_id), "type": "PromptManager", "widgets_values": [widget]}
for node_id, widget in zip(("17", "113", "119"), widgets)
]
+ [{"id": 7, "type": "CLIPTextEncode", "widgets_values": [parity.NEG]}],
"links": [],
}
return {"prompt": prompt, "workflow": workflow}
class TestOverwrittenPromptManagerText(unittest.TestCase):
"""R1 (comfy-metadata.js:52-86): never resolve to the overwritten text."""
def test_overwritten_text_is_refused_not_resolved(self):
result = _inspect(_overwritten())
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "malformed")
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
self.assertIsNone(result["positive"])
def test_the_inspector_passes_the_workflow_to_the_resolver(self):
metadata = _overwritten()
evidence = inspect_prompt_evidence(
{
"prompt": json.dumps(metadata["prompt"]),
"workflow": json.dumps(metadata["workflow"]),
}
)
self.assertEqual(evidence["resolution"]["reason_code"], "malformed")
self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
self.assertNotEqual(evidence["prompts"]["positive"]["text"], "a bird")
self.assertEqual(
evidence["prompts"]["positive"]["message"],
"Prompt metadata in this file is malformed.",
)
def test_untouched_shapes_still_resolve(self):
cases = {
# One PromptManager node, or several whose widgets agree.
"single": ("a bird",),
"agreeing widgets": ("a bird", "a bird", "a bird"),
}
for label, widgets in cases.items():
with self.subTest(label):
metadata = _overwritten()
metadata["workflow"]["nodes"] = [
node
for node in metadata["workflow"]["nodes"]
if node["type"] != "PromptManager"
] + [
{"id": node_id, "type": "PromptManager", "widgets_values": [text]}
for node_id, text in zip((17, 113, 119), widgets)
]
self.assertEqual(_inspect(metadata)["state"], "resolved")
# Distinct API texts are what ran, whatever the widgets say.
metadata = _overwritten()
metadata["prompt"]["113"]["inputs"]["text"] = "a dog"
metadata["prompt"]["119"]["inputs"]["text"] = "a cat"
metadata["prompt"]["17"]["inputs"]["text"] = "a fish"
self.assertEqual(_texts(_inspect(metadata)), ("a fish", parity.NEG))
# A PromptManagerPair widget is its positive and negative: not the signature.
metadata = _overwritten()
for node in metadata["workflow"]["nodes"]:
node["type"] = node["type"].replace("PromptManager", "PromptManagerPair")
self.assertEqual(_inspect(metadata)["state"], "resolved")
# No workflow, or a malformed one, cannot show the signature.
orphans = {
"nodes": [{"id": 5, "type": "PromptManager", "widgets_values": ["a"]}]
}
for workflow in (None, "nodes", {"nodes": "x"}, {"nodes": [1, None]}, orphans):
with self.subTest(workflow=workflow):
metadata = _overwritten()
metadata["workflow"] = workflow
self.assertEqual(_inspect(metadata)["state"], "resolved")
def test_an_untouched_branch_beside_overwritten_nodes_resolves(self):
# Only the overwritten nodes are refused: the negative is not one.
metadata = _overwritten()
metadata["prompt"]["3"]["inputs"]["positive"] = ["7", 0]
self.assertEqual(_texts(_inspect(metadata)), (parity.NEG, parity.NEG))
def test_overwritten_nodes_inside_subgraphs_are_refused(self):
# Review M-1: subgraph nodes run as "<instance>:<inner>" API IDs.
def nested(depth: int = 1) -> dict:
metadata = _overwritten()
prompt, workflow = metadata["prompt"], metadata["workflow"]
prefix = ":".join(["50", "60", "70"][:depth]) + ":"
for node_id in ("17", "113", "119"):
prompt[prefix + node_id] = prompt.pop(node_id)
prompt["3"]["inputs"]["positive"] = [prefix + "17", 0]
inner = [n for n in workflow["nodes"] if n["type"] == "PromptManager"]
outer = [n for n in workflow["nodes"] if n["type"] != "PromptManager"]
subgraphs = []
for level in range(depth, 0, -1):
nodes = (
inner
if level == depth
else [{"id": [50, 60, 70][level], "type": f"sub-{level + 1}"}]
)
subgraphs.append({"id": f"sub-{level}", "nodes": nodes})
metadata["workflow"] = {
"nodes": outer + [{"id": 50, "type": "sub-1", "widgets_values": []}],
"links": [],
"definitions": {"subgraphs": subgraphs},
}
return metadata
for depth in (1, 2, 3):
with self.subTest(depth=depth):
for anchor in (None, "9"):
result = _inspect(nested(depth), output_node_id=anchor)
self.assertEqual(
result["reason"], resolver.OVERWRITTEN_PROMPT_REASON, anchor
)
# A subgraph definition with no instance runs nothing.
metadata = nested()
metadata["workflow"]["nodes"] = metadata["workflow"]["nodes"][:-1]
self.assertEqual(_inspect(metadata)["state"], "resolved")
# Malformed definitions never raise.
for definitions in ("x", {"subgraphs": "x"}, {"subgraphs": [1, {"id": 3}]}):
metadata = nested()
metadata["workflow"]["definitions"] = definitions
self.assertEqual(_inspect(metadata)["state"], "resolved", definitions)
# A self-referencing subgraph is bounded.
metadata = nested()
metadata["workflow"]["definitions"]["subgraphs"].append(
{"id": "loop", "nodes": [{"id": 1, "type": "loop"}]}
)
metadata["workflow"]["nodes"].append({"id": 99, "type": "loop"})
self.assertEqual(
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
)
def test_object_widget_values_name_the_text(self):
metadata = _overwritten()
for node in metadata["workflow"]["nodes"]:
node["widgets_values"] = {"text": node["widgets_values"][0]}
self.assertEqual(
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
)
def test_the_workflow_is_parsed_only_for_prompt_manager_graphs(self):
# Review M-4: no PromptManager-like node, no workflow parse.
from utils import prompt_inspector
prompt = parity.build("f02")["prompt"]
with unittest.mock.patch.object(
prompt_inspector,
"_parsed_workflow",
wraps=prompt_inspector._parsed_workflow,
) as parsed:
inspect_prompt_evidence(
{"prompt": json.dumps(prompt), "workflow": json.dumps({"nodes": []})}
)
self.assertEqual(parsed.call_count, 0)
metadata = _overwritten()
inspect_prompt_evidence(
{
"prompt": json.dumps(metadata["prompt"]),
"workflow": json.dumps(metadata["workflow"]),
}
)
self.assertEqual(parsed.call_count, 1)
def test_default_profile_ignores_the_workflow(self):
result = resolve_prompt_pair(_overwritten())
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), ("a bird", parity.NEG))
def _historical(class_type: str, slot: int, inputs: dict, kind: str) -> dict:
"""``f02`` whose positive reads ``class_type`` output ``slot`` (node 10).
A conditioning output feeds the sampler directly; a string output feeds a
CLIPTextEncode (node 6).
"""
prompt = parity.build("f02")["prompt"]
prompt["10"] = {"class_type": class_type, "inputs": {"clip": ["4", 1], **inputs}}
if kind == "conditioning":
prompt["3"]["inputs"]["positive"] = ["10", slot]
else:
prompt["6"]["inputs"]["text"] = ["10", slot]
return {"prompt": prompt}
class TestHistoricalPromptManagerNodes(unittest.TestCase):
"""R7: this repository's node classes up to 07a5af3, with their own join."""
COMPOSED = {"text": " a cat ", "prepend_text": " trig ", "append_text": "sky "}
def test_v2_outputs_and_comma_join(self):
inputs = {**self.COMPOSED, "negative_text": " bad hands "}
for slot, kind, expected in (
(0, "conditioning", "trig, a cat, sky"),
(1, "conditioning", "bad hands"),
(2, "string", "trig, a cat, sky"),
(3, "string", "bad hands"),
):
with self.subTest(slot=slot):
result = _inspect(_historical("PromptManagerV2", slot, inputs, kind))
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["positive"]["text"], expected)
def test_comma_rule_matches_the_historical_code(self):
cases = (
({"text": "a cat", "prepend_text": "trig,"}, "trig,a cat"),
({"text": "a cat,", "append_text": "sky"}, "a cat,sky"),
({"text": "", "prepend_text": "trig", "append_text": "sky"}, "trig, sky"),
({"text": " ", "prepend_text": " ", "append_text": "sky"}, "sky"),
({"text": "a cat"}, "a cat"),
# Review M-5: an explicit null part is skipped, as ``if prepend_text:``.
({"text": "a cat", "prepend_text": None, "append_text": None}, "a cat"),
)
for inputs, expected in cases:
with self.subTest(inputs=inputs):
result = _inspect(_historical("PromptManagerV2", 2, inputs, "string"))
self.assertEqual(result["positive"]["text"], expected)
def test_single_role_classes(self):
cases = (
("PromptManagerPositive", 0, "conditioning", "trig, a cat, sky"),
("PromptManagerPositive", 1, "string", "trig, a cat, sky"),
("PromptManagerDetailer", 0, "conditioning", "trig, a cat, sky"),
("PromptManagerDetailer", 1, "string", "trig, a cat, sky"),
("PromptManagerV2Text", 0, "string", "trig, a cat, sky"),
("PromptManagerPositiveText", 0, "string", "trig, a cat, sky"),
("PromptManagerNegativeText", 0, "string", "trig, a cat, sky"),
# The negative-only encoder never composes.
("PromptManagerNegative", 0, "conditioning", "a cat"),
("PromptManagerNegative", 1, "string", "a cat"),
)
for class_type, slot, kind, expected in cases:
with self.subTest(node=class_type, slot=slot):
result = _inspect(_historical(class_type, slot, self.COMPOSED, kind))
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["positive"]["text"], expected)
result = _inspect(
_historical(
"PromptManagerV2Text",
1,
{"text": "a cat", "negative_text": " bad "},
"string",
)
)
self.assertEqual(result["positive"]["text"], "bad")
def test_optional_negative_defaults_to_empty(self):
result = _inspect(
_historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
)
self.assertEqual(result["positive"]["text"], "a cat")
prompt = _historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
prompt["prompt"]["3"]["inputs"]["negative"] = ["10", 1]
self.assertEqual(_texts(_inspect(prompt)), ("a cat", ""))
def test_unlisted_outputs_and_kinds_are_not_guessed(self):
# PromptManagerDetailer output 2 is an INT prompt id.
result = _inspect(
_historical("PromptManagerDetailer", 2, self.COMPOSED, "string")
)
self.assertEqual(result["reason_code"], "unsupported-node")
# A STRING output read as conditioning is a malformed link.
result = _inspect(
_historical("PromptManagerV2", 2, self.COMPOSED, "conditioning")
)
self.assertEqual(result["reason_code"], "malformed")
def test_linked_parts_are_traced(self):
source = _historical(
"PromptManagerV2",
0,
{"text": ["11", 0], "prepend_text": ["12", 0]},
"conditioning",
)
source["prompt"]["11"] = {
"class_type": "PrimitiveString",
"inputs": {"value": "a cat"},
}
source["prompt"]["12"] = {
"class_type": "PrimitiveString",
"inputs": {"value": "trig"},
}
result = _inspect(source)
self.assertEqual(result["positive"]["text"], "trig, a cat")
self.assertEqual(
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
["10", "12", "11"],
)
def test_overwritten_historical_nodes_are_still_refused(self):
metadata = _overwritten()
for node_id in ("17", "113", "119"):
metadata["prompt"][node_id]["class_type"] = "PromptManagerV2"
for node in metadata["workflow"]["nodes"]:
if node["type"] == "PromptManager":
node["type"] = "PromptManagerV2"
self.assertEqual(_inspect(metadata)["reason_code"], "malformed")
def test_default_profile_keeps_them_unsupported(self):
result = resolve_prompt_pair(
_historical("PromptManagerV2", 0, self.COMPOSED, "conditioning")
)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], "Unsupported node semantics or output slot")
FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5"
def _parameters(text: str, *, legacy: bool = False) -> dict:
profile = resolver.DEFAULT_PROFILE if legacy else resolver.INSPECTION_PROFILE
return resolve_prompt_pair({"parameters": text}, profile=profile)
class TestA1111Text(unittest.TestCase):
"""R4 and R5 (#165, py/api/__init__.py:738-750) for inspection."""
def test_text_starting_with_the_negative_delimiter_has_no_positive(self):
for text in (
f"Negative prompt: bad\n{FOOTER}",
f"\nNegative prompt: bad\n{FOOTER}",
):
with self.subTest(text=text):
result = _parameters(text)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.EMPTY_POSITIVE_REASON)
self.assertIsNone(result["positive"])
# Two delimiters, one at the start, are still two.
result = _parameters(f"Negative prompt: a\nNegative prompt: b\n{FOOTER}")
self.assertEqual(result["state"], "ambiguous")
def test_dynamic_prompts_template_lines_follow_the_footer(self):
base = f"a cat\nNegative prompt: bad\n{FOOTER}"
for trailer in (
"\nTemplate: a {cat|dog}",
"\nTemplate: a {cat|dog}\nNegative Template: bad",
"\nNegative Template: bad",
"\nTemplate: a {cat|dog}\nNegative Template: bad\n",
):
with self.subTest(trailer=trailer):
result = _parameters(base + trailer)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), ("a cat", "bad"))
for trailer in (
"\nTemplate: a\nTemplate: b",
"\nNegative Template: bad\nTemplate: a",
"\nTemplate: a\nmore template text",
"\ntrailing",
):
with self.subTest(trailer=trailer):
result = _parameters(base + trailer)
self.assertEqual(result["reason_code"], "malformed")
def test_footer_with_only_steps(self):
result = _parameters("a cat\nNegative prompt: bad\nSteps: 20")
self.assertEqual(_texts(result), ("a cat", "bad"))
result = _parameters("a cat\nSteps: 20\nTemplate: a {cat|dog}")
self.assertEqual((result["state"], _texts(result)[0]), ("absent", "a cat"))
# A full footer wins over a bare ``Steps: N`` line in the prompt text.
result = _parameters(f"a cat\nSteps: 3\nNegative prompt: bad\n{FOOTER}")
self.assertEqual(_texts(result), ("a cat\nSteps: 3", "bad"))
result = _parameters("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20")
self.assertEqual(result["reason_code"], "malformed")
def test_default_profile_keeps_the_historical_split(self):
result = _parameters(f"Negative prompt: bad\n{FOOTER}", legacy=True)
self.assertEqual(_texts(result), ("Negative prompt: bad", None))
for text in (
f"a cat\nNegative prompt: bad\n{FOOTER}\nTemplate: a {{cat|dog}}",
"a cat\nNegative prompt: bad\nSteps: 20",
):
with self.subTest(text=text):
result = _parameters(text, legacy=True)
self.assertEqual(
result["reason"], "A1111 settings footer is not unique"
)
def _chunk(keyword: str, text: str, kind: bytes = b"tEXt") -> bytes:
body = text.encode("latin-1")
if kind == b"zTXt":
body = b"\0" + zlib.compress(body)
elif kind == b"iTXt":
body = b"\0\0\0\0" + text.encode("utf-8")
return parity._raw_chunk(kind, keyword.encode("latin-1") + b"\0" + body)
def _batch(index: int, positive: str = "a cat", seed: int = 5) -> str:
return (
f"{positive}\nNegative prompt: bad\nSteps: 20, Sampler: Euler, "
f"CFG scale: 7, Seed: {seed}, Batch index: {index}, Batch size: 12"
)
class TestBatchIndexDuplicates(unittest.TestCase):
"""R3: an accumulating batch saver repeats ``parameters`` per batch item."""
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.directory = Path(temporary.name)
def png(self, chunks, name="batch.png"):
buffer = io.BytesIO()
with Image.new("RGB", (4, 4), (40, 80, 120)) as image:
image.save(buffer, format="PNG")
data = buffer.getvalue()
iend = data.rindex(b"IEND") - 4
path = self.directory / name
path.write_bytes(data[:iend] + b"".join(chunks) + data[iend:])
return path
def test_copies_differing_only_in_batch_index_read_as_the_last(self):
cases = {
"same length": [0, 1, 2],
"growing length": [8, 9, 10, 11],
"any order": [3, 0],
"repeat of an earlier copy": [0, 1, 0],
}
for label, indexes in cases.items():
with self.subTest(label):
path = self.png([_chunk("parameters", _batch(i)) for i in indexes])
read = read_inspection_file(path)
self.assertEqual(read.metadata["parameters"], _batch(indexes[-1]))
self.assertEqual(read.record_formats["parameters"], "original-text")
evidence = inspect_prompt_evidence(read.metadata)
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["text"], "a cat")
# Encodings may differ; the decoded copies are compared.
path = self.png(
[
_chunk("parameters", _batch(0)),
_chunk("parameters", _batch(1), b"zTXt"),
_chunk("parameters", _batch(2), b"iTXt"),
]
)
self.assertEqual(read_inspection_file(path).metadata["parameters"], _batch(2))
def test_any_other_difference_is_still_a_conflict(self):
cases = {
"positive": [_batch(0), _batch(1, positive="a dog")],
"seed": [_batch(0), _batch(1, seed=6)],
"no batch index": [_batch(0), _batch(0).replace(", Batch index: 0", "")],
"two batch indexes": [_batch(0), _batch(1) + ", Batch index: 2"],
"prose": [_batch(0), _batch(1).replace("a cat", "a cat Batch index: 1")],
# Review I-1: the field is matched only in the settings footer.
"prompt field": [
_batch(0).replace("a cat", "a cat, Batch index: 0"),
_batch(0).replace("a cat", "a cat, Batch index: 1"),
],
"prompt only": [
"a cat, Batch index: 0\nNegative prompt: bad\nSteps: 20, Seed: 5",
"a cat, Batch index: 1\nNegative prompt: bad\nSteps: 20, Seed: 5",
],
"negative field": [
_batch(0).replace("bad", "bad, Batch index: 0"),
_batch(1).replace("bad", "bad, Batch index: 1"),
],
"no footer": [
"a cat\nBatch index: 0, Batch size: 2",
"a cat\nBatch index: 1, Batch size: 2",
],
}
for label, texts in cases.items():
with self.subTest(label):
path = self.png([_chunk("parameters", text) for text in texts])
with self.assertRaisesRegex(
MetadataReadError, "Duplicate prompt metadata"
):
read_inspection_file(path)
# The rule is only for ``parameters``: prompt graphs stay byte-identical.
prompt = json.dumps(parity.build("f02")["prompt"])
path = self.png([_chunk("prompt", prompt), _chunk("prompt", prompt + " ")])
with self.assertRaisesRegex(MetadataReadError, "Duplicate prompt metadata"):
read_inspection_file(path)
def test_decoded_copies_are_bounded(self):
# Small compressed copies must not decode unbounded text.
text = _batch(0) + "," + " " * (DUPLICATE_ROLE_BYTES // 8)
chunks = [
_chunk("parameters", text.replace("index: 0", f"index: {i}"), b"zTXt")
for i in range(10)
]
path = self.png(chunks)
with self.assertRaisesRegex(MetadataReadError, "exceeds inspection limits"):
read_inspection_file(path)
# The kept copy still fits the role budget when a later one is longer.
from utils.prompt_inspector import ROLE_METADATA_BYTES
first = _batch(9)
first += "," + " " * (ROLE_METADATA_BYTES - len(first) - 1)
later = first.replace("index: 9", "index: 10")
for copy in (later, later + " " * ROLE_METADATA_BYTES):
with self.subTest(size=len(copy)):
path = self.png(
[
_chunk("parameters", first, b"zTXt"),
_chunk("parameters", copy, b"zTXt"),
]
)
with self.assertRaisesRegex(
MetadataReadError, "exceeds inspection limits"
):
read_inspection_file(path)
def _f02(**changes) -> dict:
"""``f02`` (KSampler 3, encoders 6 and 7) with nodes added or replaced."""
prompt = parity.build("f02")["prompt"]
prompt.update(changes)
return prompt
def _stage(class_type: str, image_input: str, positive=("6", 0), **inputs) -> dict:
"""``f19``'s shape with ``class_type`` as the detailer stage (node 50)."""
prompt = parity.build("f19")["prompt"]
if "upscale_model" in inputs:
prompt["12"] = {"class_type": "UpscaleModelLoader", "inputs": {}}
if "segs" in inputs:
prompt["13"] = {"class_type": "SegsDetector", "inputs": {"image": ["8", 0]}}
prompt["50"] = {
"class_type": class_type,
"inputs": {
image_input: ["8", 0],
"model": ["4", 0],
"vae": ["4", 2],
"positive": list(positive),
"negative": ["7", 0],
"seed": 7,
"steps": 12,
"cfg": 4.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.4,
**inputs,
},
}
return prompt
DETAILERS = (
("UltimateSDUpscale", "image", {"upscale_model": ["12", 0], "upscale_by": 2}),
("UltimateSDUpscaleNoUpscale", "upscaled_image", {}),
("DetailerForEach", "image", {"segs": ["13", 0], "wildcard": ""}),
)
class TestInstalledNodeSemantics(unittest.TestCase):
"""R6 entries verified against the installed node sources (see the report)."""
def test_upscale_and_segs_detailers_are_stages(self):
for class_type, image, extra in DETAILERS:
with self.subTest(node=class_type):
same = _inspect({"prompt": _stage(class_type, image, **extra)})
self.assertEqual(same["state"], "resolved")
self.assertEqual(same["sampler_id"], "50")
self.assertEqual(_texts(same), (parity.POS, parity.NEG))
other = _inspect(
{"prompt": _stage(class_type, image, ("10", 0), **extra)}
)
self.assertEqual(other["state"], "ambiguous")
self.assertEqual(other["reason_code"], "multistage-differs")
self.assertEqual(
sorted(item["sampler_id"] for item in other["candidates"]),
["3", "50"],
)
def test_a_detailer_wildcard_replaces_its_prompt(self):
# Impact Pack core.py: a non-empty wildcard is encoded instead of positive.
for class_type in ("DetailerForEach", "FaceDetailer"):
with self.subTest(node=class_type):
result = _inspect(
{"prompt": _stage(class_type, "image", wildcard="a smiling face")}
)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(result["node"], {"id": "50", "class_type": class_type})
linked = _stage(class_type, "image", wildcard=["14", 0])
linked["14"] = {"class_type": "WildcardSource", "inputs": {}}
result = _inspect({"prompt": linked})
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "14", "class_type": "WildcardSource"}
)
for empty in ("", None):
result = _inspect(
{"prompt": _stage(class_type, "image", wildcard=empty)}
)
self.assertEqual(result["state"], "resolved", empty)
def test_conditioning_passthroughs_keep_each_role(self):
pair = {"positive": ["6", 0], "negative": ["7", 0]}
cases = {
"ControlNetInpaintingAliMamaApply": (
{
**pair,
"control_net": ["11", 0],
"vae": ["4", 2],
"image": ["12", 0],
"mask": ["12", 1],
"strength": 1.0,
},
0,
1,
),
"LTXVConditioning": ({**pair, "frame_rate": 25.0}, 0, 1),
"FluxKontextMultiReferenceLatentMethod": (
{"conditioning": ["6", 0], "reference_latents_method": "offset"},
0,
None,
),
}
for class_type, (inputs, positive, negative) in cases.items():
with self.subTest(node=class_type):
prompt = _f02(**{"20": {"class_type": class_type, "inputs": inputs}})
prompt["3"]["inputs"]["positive"] = ["20", positive]
if negative is not None:
prompt["3"]["inputs"]["negative"] = ["20", negative]
result = _inspect({"prompt": prompt})
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
self.assertEqual(
result["positive"]["provenance"]["path"][0]["node_id"], "20"
)
if negative is None:
prompt["3"]["inputs"]["negative"] = ["20", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_text_nodes(self):
prompt = _f02(
**{
"6": {
"class_type": "Text to Conditioning",
"inputs": {"clip": ["4", 1], "text": ["21", 0]},
},
"21": {"class_type": "DisplayText", "inputs": {"text": ["22", 0]}},
"22": {"class_type": "TextInput", "inputs": {"text": parity.POS}},
}
)
result = _inspect({"prompt": prompt})
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
self.assertEqual(
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
["6", "21", "22"],
)
# Text to Conditioning has one output; TextInput reads only ``text``.
prompt["3"]["inputs"]["positive"] = ["6", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_overwritten_text_behind_a_new_passthrough_is_still_refused(self):
# R1 before R6: the pass-through must not let overwritten text resolve.
metadata = _overwritten()
metadata["prompt"]["20"] = {
"class_type": "FluxKontextMultiReferenceLatentMethod",
"inputs": {"conditioning": ["17", 0], "reference_latents_method": "offset"},
}
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
result = _inspect(metadata)
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
def test_default_profile_keeps_them_unsupported(self):
for class_type, image, extra in DETAILERS:
with self.subTest(node=class_type):
result = resolve_prompt_pair(
{"prompt": _stage(class_type, image, **extra)}
)
# The historical rule: the one KSampler, whatever reads its image.
self.assertEqual(result["sampler_id"], "3")
prompt = _f02(
**{"6": {"class_type": "Text to Conditioning", "inputs": {"text": "a"}}}
)
self.assertEqual(
resolve_prompt_pair({"prompt": prompt})["reason"],
"Unsupported node semantics or output slot",
)
def _sampled(class_type: str, latent: str = "latent_image", **inputs) -> dict:
"""``f02`` with its KSampler (node 3) replaced by ``class_type``."""
prompt = _f02()
sampler = dict(prompt["3"]["inputs"], **inputs)
sampler[latent] = sampler.pop("latent_image")
prompt["3"] = {"class_type": class_type, "inputs": sampler}
return prompt
SAMPLERS = (
("ClownsharKSampler_Beta", "latent_image", "options"),
("KSampler (Efficient)", "latent_image", "script"),
("ttN KSampler_v2", "latent", "adv_xyPlot"),
)
class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
"""R6 entries for nodes not installed here, read from their pinned sources.
Each source is the commit the maintainer's saved workflows name in the
node's ``cnr_id``/``ver`` properties (the report lists repository, commit,
file and line).
"""
def test_ksampler_shaped_samplers_resolve(self):
for class_type, latent, _ in SAMPLERS:
with self.subTest(node=class_type):
result = _inspect({"prompt": _sampled(class_type, latent)})
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["sampler_id"], "3")
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
def test_inputs_that_replace_the_prompt_are_not_followed(self):
# RES4LYF options may carry a guider; Efficiency scripts and the ttN
# XY plot re-run the sampler with other settings.
for class_type, latent, override in SAMPLERS:
with self.subTest(node=class_type):
prompt = _sampled(class_type, latent, **{override: ["30", 0]})
prompt["30"] = {"class_type": "SomeScript", "inputs": {}}
result = _inspect({"prompt": prompt})
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "30", "class_type": "SomeScript"}
)
def test_chained_samplers_are_stages(self):
prompt = _sampled("ClownsharKSampler_Beta")
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
prompt["8"]["inputs"]["samples"] = ["31", 0]
# Unanchored, two listed samplers are two branches, as for KSampler.
self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
result = _inspect({"prompt": prompt}, output_node_id="9")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_seed_variance_enhancer_passes_its_conditioning(self):
prompt = _f02(
**{
"20": {
"class_type": "SeedVarianceEnhancer",
"inputs": {"conditioning": ["6", 0], "seed": 1, "strength": 20.0},
}
}
)
prompt["3"]["inputs"]["positive"] = ["20", 0]
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
def test_overwritten_text_behind_seed_variance_is_still_refused(self):
# R1 before R6: the recon's 24 files differ from this only in names.
metadata = _overwritten()
metadata["prompt"]["20"] = {
"class_type": "SeedVarianceEnhancer",
"inputs": {"conditioning": ["17", 0], "seed": 1},
}
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
result = _inspect(metadata)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
def test_instantid_advanced_outputs(self):
prompt = _f02(
**{
"20": {
"class_type": "ApplyInstantIDAdvanced",
"inputs": {
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"ip_weight": 0.8,
},
}
}
)
prompt["3"]["inputs"].update(positive=["20", 1], negative=["20", 2])
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
prompt["3"]["inputs"]["positive"] = ["20", 0] # output 0 is the MODEL
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_crystools_conditioning_switch(self):
def switch(condition) -> dict:
prompt = _f02(
**{
"11": _pm_encoder("a dog"),
"20": {
"class_type": "Switch conditioning [Crystools]",
"inputs": {
"on_true": ["6", 0],
"on_false": ["11", 0],
"boolean": condition,
},
},
"21": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
}
)
prompt["3"]["inputs"]["positive"] = ["20", 0]
return _inspect({"prompt": prompt})
self.assertEqual(_texts(switch(True))[0], parity.POS)
self.assertEqual(_texts(switch(False))[0], "a dog")
result = switch(["21", 0])
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
sorted(_texts(item)[0] for item in result["candidates"]),
sorted([parity.POS, "a dog"]),
)
def test_smz_encoder_reads_text_unless_sdxl_may_apply(self):
def smz(**inputs) -> dict:
node = {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"clip": ["4", 1],
"text": "a cat",
"text_g": "a cat",
"text_l": "a cat",
"with_SDXL": False,
"parser": "comfy",
**inputs,
},
}
return _inspect({"prompt": _f02(**{"6": node})})
self.assertEqual(_texts(smz(text_g="", text_l="x")), ("a cat", parity.NEG))
# with_SDXL: the connected CLIP decides at run time which inputs are read.
self.assertEqual(_texts(smz(with_SDXL=True)), ("a cat", parity.NEG))
for parts in ({"text_g": "x"}, {"text_l": ""}, {"text": "x"}):
with self.subTest(parts=parts):
result = smz(with_SDXL=True, **parts)
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(
result["node"], {"id": "6", "class_type": "smZ CLIPTextEncode"}
)
linked = smz(with_SDXL=["21", 0])
self.assertEqual(linked["reason_code"], "unsupported-node")
for broken in ({"with_SDXL": "yes"}, {"with_SDXL": None}):
with self.subTest(flag=broken):
self.assertEqual(smz(**broken)["reason_code"], "malformed")
def test_string_sources(self):
cases = (
("ttN text", "text"),
("CR Text", "text"),
("CR Prompt Text", "prompt"),
("TextBoxMira", "text"),
)
for class_type, name in cases:
with self.subTest(node=class_type):
prompt = _f02(
**{"10": {"class_type": class_type, "inputs": {name: parity.POS}}}
)
prompt["6"]["inputs"]["text"] = ["10", 0]
self.assertEqual(
_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)
)
# Output 1 of the Comfyroll nodes is their help URL.
prompt["6"]["inputs"]["text"] = ["10", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_default_profile_keeps_them_unsupported(self):
for class_type, latent, _ in SAMPLERS:
with self.subTest(node=class_type):
result = resolve_prompt_pair({"prompt": _sampled(class_type, latent)})
self.assertEqual(result["reason"], "No supported sampler pair found")
if __name__ == "__main__":
unittest.main()
+486
View File
@@ -0,0 +1,486 @@
"""Non-resolved prompt outcomes carry one short, truthful line per role."""
import ast
import json
import unittest
import unittest.mock
from pathlib import Path
from dataclasses import replace
from utils import prompt_pair_resolver
from utils.inspection_outcomes import (
_UNCODED_LINES,
CLASS_NAME_LENGTH,
REASON_CODES,
outcome_message,
project_resolution,
)
from utils.inspection_response import encode_inspection_response
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import (
EMPTY_POSITIVE_REASON,
INSPECTION_PROFILE,
MULTISTAGE_UNATTRIBUTED_REASON,
SEMANTICS_UNAVAILABLE_REASON,
)
LIMITS = "Prompt exceeds inspection limits."
def _encode(text: str) -> dict:
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["9", 0]}}
def _latent() -> dict:
return {
"class_type": "EmptyLatentImage",
"inputs": {"width": 8, "height": 8, "batch_size": 1},
}
def _ksampler(positive: str = "7") -> dict:
return {
"class_type": "KSampler",
"inputs": {
"positive": [positive, 0],
"negative": ["6", 0],
"latent_image": ["4", 0],
"model": ["9", 0],
"seed": 1,
"steps": 2,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
},
}
def _dual_cfg() -> dict:
return {
"1": _encode("positive"),
"6": _encode("negative"),
"2": {
"class_type": "DualCFGGuider",
"inputs": {
"cond1": ["1", 0],
"cond2": ["1", 0],
"negative": ["6", 0],
"model": ["9", 0],
},
},
"3": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"guider": ["2", 0],
"noise": ["9", 0],
"sampler": ["9", 0],
"sigmas": ["9", 0],
"latent_image": ["4", 0],
},
},
"4": _latent(),
}
def _runtime() -> dict:
return {
"1": {"class_type": "PromptSearchList", "inputs": {"search": "cat"}},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["1", 0], "clip": ["9", 0]},
},
"6": _encode("negative"),
"3": _ksampler(),
"4": _latent(),
}
def _composite() -> dict:
graph = _runtime()
graph["7"] = {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["8", 0]},
}
graph["8"] = _encode("other")
return graph
def _messages(evidence: dict) -> dict:
return {role: side.get("message") for role, side in evidence["prompts"].items()}
def _statuses(evidence: dict) -> dict:
return {role: side["status"] for role, side in evidence["prompts"].items()}
class TestOutcomeMessages(unittest.TestCase):
def assert_unresolved_with(self, evidence: dict, message: str) -> None:
self.assertEqual(
_statuses(evidence), {"positive": "unresolved", "negative": "unresolved"}
)
self.assertEqual(
_messages(evidence), {"positive": message, "negative": message}
)
def test_unsupported_node_status_names_the_class(self):
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
self.assertEqual(evidence["resolution"]["reason_code"], "unsupported-node")
self.assert_unresolved_with(
evidence,
"Prompt uses a node the inspector does not support (DualCFGGuider).",
)
def test_runtime_and_composite_statuses(self):
self.assert_unresolved_with(
inspect_prompt_evidence({"prompt": _runtime()}),
"Prompt is chosen at run time (PromptSearchList).",
)
self.assert_unresolved_with(
inspect_prompt_evidence({"prompt": _composite()}),
"Prompt combines several texts (ConditioningCombine).",
)
def test_limit_status(self):
tight = replace(INSPECTION_PROFILE, per_candidate_visits=1, total_visits=1)
evidence = inspect_prompt_evidence({"prompt": _runtime()}, profile=tight)
self.assertEqual(evidence["resolution"]["reason_code"], "work-limit")
self.assert_unresolved_with(evidence, LIMITS)
for code in ("work-limit", "depth-limit", "text-limit", "graph-ceiling"):
self.assertEqual(
outcome_message({"state": "unresolved", "reason_code": code}), LIMITS
)
def test_every_closed_code_and_known_uncoded_reason_has_a_line(self):
for code in REASON_CODES:
message = outcome_message({"state": "unresolved", "reason_code": code})
self.assertIsInstance(message, str, code)
self.assertTrue(message.endswith("."), code)
self.assertNotIn("could not be resolved", message)
for reason, expected in (
(EMPTY_POSITIVE_REASON, "Positive prompt is empty."),
(
SEMANTICS_UNAVAILABLE_REASON,
"The inspector's node table is unavailable.",
),
(
MULTISTAGE_UNATTRIBUTED_REASON,
"Several sampler stages were found; their prompts were not compared.",
),
(
"Media metadata could not be read",
"Metadata in this file could not be read.",
),
):
resolution = {"state": "unresolved", "reason": reason, "reason_code": None}
self.assertEqual(outcome_message(resolution), expected)
def test_named_codes_without_a_class_omit_the_name(self):
for code, line in (
("unsupported-node", "Prompt uses a node the inspector does not support."),
("runtime-value", "Prompt is chosen at run time."),
("composite", "Prompt combines several texts."),
):
for node in (None, {"id": "7", "class_type": None}):
resolution = {"state": "unresolved", "reason_code": code, "node": node}
self.assertEqual(outcome_message(resolution), line)
def test_class_name_is_bounded(self):
resolution = {
"state": "unresolved",
"reason_code": "unsupported-node",
"node": {"id": "7", "class_type": "N" * 500},
}
self.assertEqual(
outcome_message(resolution),
"Prompt uses a node the inspector does not support "
f"({'N' * CLASS_NAME_LENGTH}).",
)
self.assertEqual(CLASS_NAME_LENGTH, 200)
def test_resolved_ambiguous_absent_and_unknown_outcomes_carry_no_line(self):
for resolution in (
{"state": "resolved", "reason_code": None},
{"state": "ambiguous", "reason_code": "multistage-differs"},
{"state": "ambiguous", "reason_code": None, "reason": "Multiple"},
{"state": "absent", "reason_code": None, "reason": "No parsed"},
{"state": "unresolved", "reason_code": "guess"},
{"state": "unresolved", "reason_code": None, "reason": "Anything else"},
None,
):
self.assertIsNone(outcome_message(resolution), resolution)
def test_inspector_lines_for_no_sampler_malformed_and_empty_positive(self):
evidence = inspect_prompt_evidence({"prompt": {"1": _encode("x")}})
self.assert_unresolved_with(evidence, "No supported sampler was found.")
evidence = inspect_prompt_evidence({"prompt": "not-json"})
self.assert_unresolved_with(
evidence, "Prompt metadata in this file is malformed."
)
graph = _runtime()
graph["7"] = _encode(" ")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["reason"], EMPTY_POSITIVE_REASON)
self.assert_unresolved_with(evidence, "Positive prompt is empty.")
def test_a_resolved_pair_with_an_empty_positive_explains_only_that_role(self):
side = {"text": " ", "provenance": {"text_source": "graph-inferred"}}
negative = {"text": "kept", "provenance": {"text_source": "graph-inferred"}}
resolved = {
"state": "resolved",
"positive": side,
"negative": negative,
"sampler_id": "3",
"reason": "",
"reason_code": None,
"node": None,
}
with unittest.mock.patch(
"utils.prompt_inspector.resolve_prompt_pair", return_value=resolved
):
evidence = inspect_prompt_evidence({"prompt": {}})
self.assertEqual(
evidence["prompts"]["positive"],
{
"status": "unresolved",
"text": None,
"source": None,
"message": "Positive prompt is empty.",
},
)
self.assertEqual(evidence["prompts"]["negative"]["text"], "kept")
self.assertNotIn("message", evidence["prompts"]["negative"])
def test_candidates_and_absence_keep_their_status_lines(self):
graph = _runtime()
graph["7"] = _encode("positive")
graph["5"] = _ksampler()
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
evidence = inspect_prompt_evidence({})
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
graph = _runtime()
graph["7"] = _encode("positive")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
class TestResolutionProjection(unittest.TestCase):
def test_closed_codes_and_named_nodes_pass_unchanged(self):
for code in REASON_CODES:
resolution = {
"state": "unresolved",
"sampler_id": "3",
"reason": "r",
"reason_code": code,
"node": None,
}
self.assertEqual(project_resolution(resolution), resolution)
named = {
"state": "unresolved",
"sampler_id": None,
"reason": "r",
"reason_code": "composite",
"node": {"id": "7", "class_type": None},
}
self.assertEqual(project_resolution(named), named)
def test_unknown_codes_and_unbounded_nodes_fail_closed(self):
base = {"state": "unresolved", "sampler_id": None, "reason": "r"}
for code, node in (
("guess", None),
(1, None),
("malformed", {"id": "7", "class_type": "X"}),
(None, {"id": "7", "class_type": "X"}),
("unsupported-node", {"id": 7, "class_type": "X"}),
("unsupported-node", {"id": "", "class_type": "X"}),
("unsupported-node", {"id": "7"}),
("unsupported-node", {"id": "7", "class_type": 7}),
("unsupported-node", {"id": "7", "class_type": "X", "extra": 1}),
("unsupported-node", "X"),
):
projected = project_resolution({**base, "reason_code": code, "node": node})
self.assertIsNone(projected["node"], (code, node))
if code not in REASON_CODES:
self.assertIsNone(projected["reason_code"], code)
for state in ("resolved", "absent"):
projected = project_resolution(
{**base, "state": state, "reason_code": "work-limit", "node": None}
)
self.assertIsNone(projected["reason_code"])
long_name = project_resolution(
{
**base,
"reason_code": "runtime-value",
"node": {"id": "7" * 300, "class_type": "N" * 300},
}
)["node"]
self.assertEqual(
long_name, {"id": "7" * CLASS_NAME_LENGTH, "class_type": "N" * 200}
)
unnamed = project_resolution(
{
**base,
"reason_code": "composite",
"node": {"id": "7", "class_type": ""},
}
)["node"]
self.assertEqual(unnamed, {"id": "7", "class_type": None})
def test_wire_carries_role_lines_and_projected_resolution(self):
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
resolution = dict(evidence["resolution"], reason_code="guess")
body = json.loads(
encode_inspection_response(
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
root_scope="a" * 64,
file={
"observation": "current-file",
"source_label": "output",
"media_type": "image",
"extension": ".png",
"size": 1,
"modified_time": 1,
},
prompts=evidence["prompts"],
resolution=resolution,
inspection=None,
library={"status": "none", "tags": []},
)
)
self.assertEqual(body["prompts"], evidence["prompts"])
self.assertIsNone(body["resolution"]["reason_code"])
self.assertIsNone(body["resolution"]["node"])
# Uncoded unresolved reasons only the default (ingestion) profile emits; the
# inspector never shows them, so they need no line.
_DEFAULT_PROFILE_ONLY = frozenset(
{"Cyclic or excessive latent ancestry", "Resolution limit reached"}
)
def _uncoded_unresolved_reasons() -> set:
"""Every literal reason the resolver can return as unresolved without a code.
Reads the source: ``_Unresolved(reason)`` without a code, and
``_result("unresolved", ..., reason=...)`` without ``reason_code``. A reason
is a string literal or a module constant naming one.
"""
source = Path(prompt_pair_resolver.__file__).read_text(encoding="utf-8")
tree = ast.parse(source)
def literal(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
value = getattr(prompt_pair_resolver, node.id, None)
return value if isinstance(value, str) else None
return None
found = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
continue
keywords = {item.arg: item.value for item in node.keywords}
if node.func.id == "_Unresolved":
if len(node.args) < 2 and "code" not in keywords and node.args:
reason = literal(node.args[0])
if reason is None:
raise AssertionError(f"line {node.lineno}: unreadable reason")
found.add(reason)
elif node.func.id == "_result" and node.args:
state = literal(node.args[0])
coded = "reason_code" in keywords or len(node.args) > 6
reason = keywords.get(
"reason", node.args[4] if len(node.args) > 4 else None
)
if state == "unresolved" and not coded and reason is not None:
text = literal(reason)
if text is None:
raise AssertionError(f"line {node.lineno}: unreadable reason")
found.add(text)
return found
class TestEveryUncodedOutcomeHasALine(unittest.TestCase):
"""Task 2.7 review M1: a new uncoded unresolved reason fails here."""
def test_resolver_reasons_without_a_code_have_a_line(self):
reasons = _uncoded_unresolved_reasons()
self.assertIn(EMPTY_POSITIVE_REASON, reasons, "the scan sees the source")
missing = reasons - set(_UNCODED_LINES) - _DEFAULT_PROFILE_ONLY
self.assertEqual(missing, set(), "add a code or a line in inspection_outcomes")
# The allowance names only reasons the source still emits.
self.assertLessEqual(_DEFAULT_PROFILE_ONLY, reasons)
def test_default_profile_only_reasons_are_coded_in_inspection(self):
# The limit outcome is uncoded only on the default profile.
source = {"prompt": {"1": {"class_type": "KSampler", "inputs": {}}}}
for limited in ({"max_depth": 0}, {"max_text_length": 0}):
with self.subTest(**limited):
default = prompt_pair_resolver.resolve_prompt_pair(source, **limited)
inspected = prompt_pair_resolver.resolve_prompt_pair(
source, profile=INSPECTION_PROFILE, **limited
)
self.assertEqual(default["reason"], "Resolution limit reached")
self.assertIsNone(default["reason_code"])
self.assertEqual(inspected["reason"], "Resolution limit reached")
self.assertIsNotNone(inspected["reason_code"])
class TestRoleMessageBound(unittest.TestCase):
"""Task 2.7 review M4: the encoder bounds role lines as the client does."""
def encode(self, prompts: dict) -> dict:
return json.loads(
encode_inspection_response(
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
root_scope="a" * 64,
file={
"observation": "current-file",
"source_label": "output",
"media_type": "image",
"extension": ".png",
"size": 1,
"modified_time": 1,
},
prompts=prompts,
resolution={"state": "unresolved", "reason": "x"},
inspection=None,
library={"status": "none", "tags": []},
)
)["prompts"]
def test_role_messages_are_bounded_text_on_unresolved_roles(self):
side = {"status": "unresolved", "text": None, "source": None}
prompts = self.encode(
{
"positive": {**side, "message": "x" * 600},
"negative": {**side, "message": "\U0001f600" * 300},
}
)
self.assertEqual(prompts["positive"]["message"], "x" * 512)
# The client counts UTF-16 code units.
self.assertLessEqual(
len(prompts["negative"]["message"].encode("utf-16-le")), 1024
)
self.assertEqual(prompts["negative"]["message"], "\U0001f600" * 256)
for message in ("", None, 7):
with self.subTest(message=message):
prompts = self.encode(
{"positive": {**side, "message": message}, "negative": side}
)
self.assertNotIn("message", prompts["positive"])
resolved = {"status": "resolved", "text": "a", "source": "graph-inferred"}
prompts = self.encode(
{"positive": {**resolved, "message": "line"}, "negative": side}
)
self.assertNotIn("message", prompts["positive"])
self.assertEqual(prompts["negative"], side)
if __name__ == "__main__":
unittest.main()
+838
View File
@@ -0,0 +1,838 @@
"""The declarative inspection node semantics table and its loader (#240, spec 7).
``web/js/inspection-semantics.json`` is the single table. Python validates it at
import and fails loudly; the browser facts tables are checked against its
``facts`` section by ``tests/js/inspection-semantics-sync.test.js``.
"""
import copy
import json
import tempfile
import unittest
import unittest.mock
from pathlib import Path
from types import MappingProxyType
from utils import inspection_semantics as semantics
from utils.inspection_semantics import SemanticsError, load_semantics
REPO = Path(__file__).resolve().parents[1]
TABLE = REPO / "web" / "js" / "inspection-semantics.json"
def _minimal():
"""A small valid table; mutation tests break exactly one thing in a copy."""
return {
"schema": 1,
"samplers": {
"KSampler": {
"strategy": "ksampler",
"conditioning": {"positive": "positive", "negative": "negative"},
"latent": "latent_image",
"facts": "ksampler",
}
},
"guiders": {"BasicGuider": {"strategy": "basic", "positive": "conditioning"}},
"conditioning": [
{
"class": "FluxGuidance",
"slot": 0,
"strategy": "passthrough",
"input": "conditioning",
}
],
"selectors": {},
"composites": {},
"encoders": [
{
"class": "CLIPTextEncode",
"slot": 0,
"strategy": "single",
"inputs": ["text"],
}
],
"strings": [],
"runtime": [],
"savers": {},
"non_file_outputs": [],
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
"facts": {
"configuration_outputs": {},
"model_outputs": {},
"model_passthroughs": [],
"scalar_outputs": {},
},
}
class _TableCase(unittest.TestCase):
def load_text(self, text):
# Windows: the file handle is closed before the directory is removed.
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "semantics.json"
path.write_text(text, encoding="utf-8")
return load_semantics(path)
def load_doc(self, doc):
return self.load_text(json.dumps(doc))
def assertRejected(self, doc, fragment):
with self.assertRaises(SemanticsError) as caught:
self.load_doc(doc)
self.assertIn(fragment, str(caught.exception))
class SpecEntriesTest(unittest.TestCase):
def test_semantics_file_validates_and_lists_spec_entries(self):
table = load_semantics()
self.assertEqual(semantics.SEMANTICS_PATH, TABLE)
self.assertEqual(
semantics.SEMANTICS_PATH,
Path(semantics.__file__).resolve().parents[1]
/ "web"
/ "js"
/ "inspection-semantics.json",
)
pair = {"positive": "positive", "negative": "negative"}
for name in ("KSampler", "KSamplerAdvanced"):
self.assertEqual(
table.samplers[name],
{
"strategy": "ksampler",
"conditioning": pair,
"latent": "latent_image",
"facts": "ksampler",
},
)
self.assertEqual(
table.samplers["SamplerCustom"],
{
"strategy": "ksampler",
"conditioning": pair,
"latent": "latent_image",
"facts": "custom",
},
)
self.assertEqual(
table.samplers["SamplerCustomAdvanced"],
{
"strategy": "custom-advanced",
"conditioning": {"guider": "guider"},
"latent": "latent_image",
"facts": "custom-advanced",
},
)
# D19: FaceDetailer is a stage over its upstream image, ksampler-shaped facts.
# A non-empty wildcard replaces the detail prompt (Impact core.py:267).
self.assertEqual(
table.samplers["FaceDetailer"],
{
"strategy": "detailer-stage",
"conditioning": pair,
"image_stage": "image",
"facts": "ksampler",
"prompt_overrides": ("wildcard",),
},
)
self.assertEqual(
table.guiders["CFGGuider"],
{"strategy": "cfg", "positive": "positive", "negative": "negative"},
)
self.assertEqual(
table.guiders["BasicGuider"],
{
"strategy": "basic",
"positive": "conditioning",
"absent_reason": "Guider has no negative input",
},
)
# D17: DualCFGGuider has two positives and stays an unsupported node.
self.assertNotIn("DualCFGGuider", table.guiders)
self.assertNotIn("DualCFGGuider", table.all_classes)
single_input = (
"FluxGuidance",
"ReferenceLatent",
"ConditioningSetArea",
"ConditioningSetAreaPercentage",
"ConditioningSetAreaStrength",
"ConditioningSetMask",
"ConditioningSetTimestepRange",
"ControlNetApply",
)
for name in single_input:
self.assertEqual(
table.conditioning[(name, 0)],
{"strategy": "passthrough", "input": "conditioning"},
)
self.assertNotIn((name, 1), table.conditioning)
for name in (
"ControlNetApplyAdvanced",
"ControlNetApplySD3",
"InpaintModelConditioning",
"WanImageToVideo",
):
self.assertEqual(
table.conditioning[(name, 0)],
{"strategy": "passthrough", "input": "positive"},
)
self.assertEqual(
table.conditioning[(name, 1)],
{"strategy": "passthrough", "input": "negative"},
)
# Slot 2 (a latent) is not conditioning; spec 7.4 handles origins.
self.assertNotIn((name, 2), table.conditioning)
self.assertEqual(
table.conditioning[("ConditioningZeroOut", 0)],
{"strategy": "zeroed", "input": "conditioning"},
)
self.assertEqual(
table.selectors["easy ifElse"],
{
"strategy": "boolean-select",
"condition": "boolean",
"branches": {"true": "on_true", "false": "on_false"},
},
)
self.assertEqual(
table.composites["ConditioningCombine"],
{"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
)
for name in ("ConditioningConcat", "ConditioningAverage"):
self.assertEqual(
table.composites[name],
{
"strategy": "combine",
"inputs": ("conditioning_to", "conditioning_from"),
},
)
encoders = {
("CLIPTextEncode", 0): ("single", ("text",)),
("CLIPTextEncodeSDXL", 0): ("parts-equal", ("text_g", "text_l")),
("CLIPTextEncodeFlux", 0): ("parts-equal", ("clip_l", "t5xxl")),
("CLIPTextEncodeSD3", 0): ("parts-equal", ("clip_l", "clip_g", "t5xxl")),
("TextEncodeQwenImageEdit", 0): ("single", ("prompt",)),
("TextEncodeQwenImageEditPlus", 0): ("single", ("prompt",)),
("TextEncodeQwenImage21", 0): ("single", ("prompt",)),
("TextEncodeQwenImage21", 1): ("single", ("negative_prompt",)),
# D11: a plain text encoder, no composition.
("KikoTextEncode", 0): ("single", ("text",)),
}
for key, (strategy, inputs) in encoders.items():
self.assertEqual(
table.encoders[key], {"strategy": strategy, "inputs": inputs}, key
)
self.assertNotIn(("TextEncodeQwenImage21", 2), table.encoders)
self.assertEqual(
table.strings[("StringConstant", 0)],
{"strategy": "literal", "inputs": ("string",)},
)
self.assertEqual(
table.strings[("StringConstantMultiline", 0)],
{
"strategy": "strip-newlines",
"inputs": ("string",),
"params": {"flag": "strip_newlines"},
},
)
self.assertEqual(
table.strings[("JoinStringMulti", 0)],
{
"strategy": "join-multi",
"inputs": ("string_1",),
"params": {
"count": "inputcount",
"delimiter": "delimiter",
"list_flag": "return_list",
"input_prefix": "string_",
},
},
)
# D18: decided at run time.
self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
comfy = "{name}_{counter:05}_.{ext}"
for name, image in (
("SaveImage", "images"),
("SaveAnimatedWEBP", "images"),
("SaveAnimatedPNG", "images"),
("SaveVideo", "video"),
("SaveWEBM", "images"),
):
self.assertEqual(
table.savers[name],
{
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": image,
"pattern": comfy,
},
)
self.assertEqual(
table.savers["VHS_VideoCombine"],
{
"strategy": "vhs-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}.{ext}",
},
)
# Until its naming is verified in pinned source it stays terminal-unknown.
self.assertNotIn("KikoSaveImage", table.all_classes)
self.assertEqual(
table.non_file_outputs,
frozenset(
{
"PreviewImage",
"PreviewAny",
"SaveImageWebsocket",
"Image Comparer (rgthree)",
}
),
)
# Not listed this slice: they stay unsupported-node with the class named.
for name in (
"ImpactWildcardProcessor",
"RegexReplace",
"LoRAfromText",
"XYZPrompt",
):
self.assertNotIn(name, table.all_classes)
# Existing code adapters stay in code, unchanged.
for name in ("PromptManager", "PromptManagerPair", "PromptManagerText"):
self.assertNotIn(name, table.all_classes)
def test_legacy_parser_ports_are_listed_with_their_source_inputs(self):
# Task 2.7b (R6): input names and slots read from each node's source;
# the report lists the file and line for every one.
table = load_semantics()
pair = {"positive": "positive", "negative": "negative"}
for name, image in (
("DetailerForEach", "image"),
("UltimateSDUpscale", "image"),
("UltimateSDUpscaleNoUpscale", "upscaled_image"),
):
entry = dict(table.samplers[name])
self.assertEqual(
entry.pop("prompt_overrides", None) is not None,
name == "DetailerForEach",
name,
)
self.assertEqual(
entry,
{
"strategy": "detailer-stage",
"conditioning": pair,
"image_stage": image,
"facts": "ksampler",
},
name,
)
for (name, slot), source in {
("ControlNetInpaintingAliMamaApply", 0): "positive",
("ControlNetInpaintingAliMamaApply", 1): "negative",
("LTXVConditioning", 0): "positive",
("LTXVConditioning", 1): "negative",
("FluxKontextMultiReferenceLatentMethod", 0): "conditioning",
}.items():
self.assertEqual(
table.conditioning[(name, slot)],
{"strategy": "passthrough", "input": source},
)
self.assertNotIn(
("FluxKontextMultiReferenceLatentMethod", 1), table.conditioning
)
self.assertEqual(
table.encoders[("Text to Conditioning", 0)],
{"strategy": "single", "inputs": ("text",)},
)
for name in ("TextInput", "DisplayText"):
self.assertEqual(
table.strings[(name, 0)], {"strategy": "literal", "inputs": ("text",)}
)
def test_stage_inputs_are_the_one_source_for_both_stage_walks(self):
# Review I-2: Python's resolver and comfy-metadata.js read these lists here.
stage_inputs = load_semantics().stage_inputs
self.assertEqual(
stage_inputs["shaped"],
(
"latent_image",
"guider",
"positive",
"negative",
"pipe",
"basic_pipe",
"detailer_pipe",
"sampler",
"sigmas",
"noise",
"text_embeds",
"hyvid_embeds",
),
)
self.assertEqual(
stage_inputs["with_model"],
("conditioning", "neg_conditioning", "samples", "latent"),
)
# The default profile's own sampler-shape names stay a subset of the table's.
from utils.prompt_pair_resolver import _SAMPLER_LIKE_INPUTS
self.assertLessEqual(set(_SAMPLER_LIKE_INPUTS), set(stage_inputs["shaped"]))
def test_facts_section_mirrors_the_browser_tables(self):
facts = load_semantics().facts
self.assertEqual(
facts["configuration_outputs"],
{
"RandomNoise": {"kind": "noise", "fields": ("noise_seed",)},
"KSamplerSelect": {"kind": "sampler", "fields": ("sampler_name",)},
"BasicScheduler": {"kind": "sigmas", "fields": ("steps", "scheduler")},
# D8: Flux2Scheduler has no scheduler input; its type names it.
"Flux2Scheduler": {
"kind": "sigmas",
"fields": ("steps",),
"type_fields": ("scheduler",),
},
"CFGGuider": {"kind": "guider", "fields": ("cfg", "model")},
"BasicGuider": {"kind": "guider", "fields": ("model",)},
},
)
self.assertEqual(
facts["model_outputs"],
{
"CheckpointLoader": "ckpt_name",
"CheckpointLoaderSimple": "ckpt_name",
"unCLIPCheckpointLoader": "ckpt_name",
"UNETLoader": "unet_name",
},
)
self.assertEqual(
facts["model_passthroughs"],
(
"LoraLoader",
"LoraLoaderModelOnly",
"ModelSamplingFlux",
"ModelSamplingAuraFlow",
"ModelSamplingSD3",
# Task 2.7b (R9): model in, model out, verified in their sources.
"Lora Loader (LoraManager)",
"Power Lora Loader (rgthree)",
"ModelPatchTorchSettings",
"CFGNorm",
"PerturbedAttentionGuidance",
"CR Apply LoRA Stack",
),
)
self.assertEqual(
facts["scalar_outputs"],
{
"PrimitiveInt": "number",
"PrimitiveFloat": "number",
"PrimitiveString": "string",
"PrimitiveStringMultiline": "string",
},
)
def test_table_is_loaded_lazily_and_once(self):
# Importing the module reads nothing; the inspection path asks for it.
self.assertFalse(hasattr(semantics, "SEMANTICS"))
semantics.get_semantics.cache_clear()
first = semantics.get_semantics()
self.assertIs(first, semantics.get_semantics())
self.assertEqual(first, load_semantics())
def test_loaded_table_is_frozen(self):
table = load_semantics()
self.assertIsInstance(table.samplers, MappingProxyType)
self.assertIsInstance(table.samplers["KSampler"], MappingProxyType)
with self.assertRaises(TypeError):
table.samplers["Other"] = {}
with self.assertRaises(TypeError):
table.samplers["KSampler"]["latent"] = "x"
with self.assertRaises(AttributeError):
table.samplers = {}
self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
class ValidationTest(_TableCase):
def test_minimal_table_loads(self):
table = self.load_doc(_minimal())
self.assertEqual(table.encoders[("CLIPTextEncode", 0)]["inputs"], ("text",))
self.assertEqual(table.runtime, frozenset())
def test_unknown_strategy_rejected(self):
for category, mutate in (
("samplers", lambda d: d["samplers"]["KSampler"].update(strategy="dual")),
# A known strategy of another category is still unknown here.
("conditioning", lambda d: d["conditioning"][0].update(strategy="single")),
("encoders", lambda d: d["encoders"][0].update(strategy="guess")),
("guiders", lambda d: d["guiders"]["BasicGuider"].update(strategy=None)),
):
with self.subTest(category=category):
doc = _minimal()
mutate(doc)
self.assertRejected(doc, "unknown strategy")
def test_strategies_are_the_closed_spec_set(self):
self.assertEqual(
dict(semantics.STRATEGIES),
{
"samplers": frozenset(
{"ksampler", "custom-advanced", "detailer-stage"}
),
"guiders": frozenset({"cfg", "basic"}),
"conditioning": frozenset({"passthrough", "zeroed"}),
"selectors": frozenset({"boolean-select"}),
"composites": frozenset({"combine"}),
"encoders": frozenset(
{"single", "parts-equal", "pm-compose", "flag-parts"}
),
"strings": frozenset(
{"literal", "strip-newlines", "join-multi", "concat"}
),
"savers": frozenset({"comfy-counter", "vhs-counter"}),
},
)
with self.assertRaises(TypeError):
semantics.STRATEGIES["samplers"] = frozenset()
def test_duplicate_output_key_rejected(self):
doc = _minimal()
doc["encoders"].append(copy.deepcopy(doc["encoders"][0]))
self.assertRejected(doc, "duplicate")
doc = _minimal()
doc["conditioning"].append(
{
"class": "CLIPTextEncode",
"slot": 0,
"strategy": "passthrough",
"input": "conditioning",
}
)
self.assertRejected(doc, "duplicate")
doc = _minimal()
doc["runtime"] = ["KSampler"]
self.assertRejected(doc, "duplicate")
doc = _minimal()
doc["non_file_outputs"] = ["PreviewImage", "PreviewImage"]
self.assertRejected(doc, "duplicate")
# JSON objects with a repeated key would silently keep the last one.
text = json.dumps(_minimal())
entry = json.dumps(_minimal()["samplers"]["KSampler"])
text = text.replace(
'"samplers": {', '"samplers": {"KSampler": ' + entry + ", ", 1
)
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("duplicate", str(caught.exception))
def test_slot_must_be_nonnegative_int(self):
for slot in (-1, 1.5, True, "0", None, 2**31):
with self.subTest(slot=slot):
doc = _minimal()
doc["encoders"][0]["slot"] = slot
self.assertRejected(doc, "slot")
doc = _minimal()
del doc["conditioning"][0]["slot"]
self.assertRejected(doc, "slot")
def test_missing_input_name_rejected(self):
cases = (
lambda d: d["samplers"]["KSampler"]["conditioning"].pop("negative"),
lambda d: d["samplers"]["KSampler"].pop("latent"),
lambda d: d["guiders"]["BasicGuider"].update(positive=""),
lambda d: d["conditioning"][0].pop("input"),
lambda d: d["encoders"][0].update(inputs=[]),
lambda d: d["encoders"][0].update(inputs=["text", "text"]),
lambda d: d["encoders"][0].update(inputs=[3]),
lambda d: d["encoders"][0].update(inputs="text"),
)
for index, mutate in enumerate(cases):
with self.subTest(case=index):
doc = _minimal()
mutate(doc)
self.assertRejected(doc, "input")
def test_entry_shape_is_strict(self):
cases = (
(lambda d: d.update(extra={}), "category"),
(lambda d: d.pop("guiders"), "category"),
(lambda d: d.update(schema=2), "schema"),
(lambda d: d["samplers"]["KSampler"].update(note="x"), "field"),
(lambda d: d["samplers"]["KSampler"].update(facts="flux"), "facts"),
(lambda d: d["guiders"]["BasicGuider"].update(negative="n"), "field"),
(lambda d: d["encoders"][0].update({"class": ""}), "class"),
(lambda d: d["facts"].update(extra={}), "facts"),
(
lambda d: d["facts"]["scalar_outputs"].update(PrimitiveInt="int"),
"facts",
),
(lambda d: d.pop("stage_inputs"), "category"),
(lambda d: d["stage_inputs"].update(extra=["x"]), "stage_inputs"),
(lambda d: d["stage_inputs"].pop("with_model"), "stage_inputs"),
(lambda d: d["stage_inputs"].update(shaped=[]), "input name"),
(lambda d: d["stage_inputs"].update(shaped="pipe"), "input name"),
(lambda d: d["stage_inputs"].update(with_model=["positive"]), "both"),
(lambda d: d["stage_inputs"].update(shaped=["model"]), "model"),
(
lambda d: d["facts"]["configuration_outputs"].update(
X={"kind": "sigmas", "fields": ["steps"], "type_fields": []}
),
"input name",
),
(
lambda d: d["facts"]["configuration_outputs"].update(
X={"kind": "sigmas", "fields": ["steps"], "type_fields": ["steps"]}
),
"type_fields",
),
(
lambda d: d["facts"]["configuration_outputs"].update(
X={"kind": "sigmas", "fields": ["steps"], "names": ["x"]}
),
"field",
),
)
for index, (mutate, fragment) in enumerate(cases):
with self.subTest(case=index):
doc = _minimal()
mutate(doc)
self.assertRejected(doc, fragment)
def test_strategy_specific_requirements(self):
doc = _minimal()
doc["samplers"]["X"] = {
"strategy": "custom-advanced",
"conditioning": {"positive": "positive", "negative": "negative"},
"latent": "latent_image",
"facts": "custom-advanced",
}
self.assertRejected(doc, "input")
doc = _minimal()
doc["samplers"]["D"] = {
"strategy": "detailer-stage",
"conditioning": {"positive": "positive", "negative": "negative"},
"facts": "ksampler",
}
self.assertRejected(doc, "input")
doc = _minimal()
doc["guiders"]["G"] = {"strategy": "cfg", "positive": "positive"}
self.assertRejected(doc, "input")
doc = _minimal()
doc["encoders"][0].update(strategy="parts-equal", inputs=["text_g"])
self.assertRejected(doc, "input")
doc = _minimal()
doc["savers"]["S"] = {
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}.{ext}",
}
self.assertRejected(doc, "pattern")
doc = _minimal()
doc["selectors"]["easy ifElse"] = {
"strategy": "boolean-select",
"condition": "boolean",
"branches": {"true": "on_true"},
}
self.assertRejected(doc, "input")
doc = _minimal()
doc["strings"].append(
{
"class": "JoinStringMulti",
"slot": 0,
"strategy": "join-multi",
"inputs": ["string_1"],
"params": {"count": "inputcount"},
}
)
self.assertRejected(doc, "params")
def test_unreadable_table_fails_loudly(self):
with self.assertRaises(SemanticsError):
self.load_text("{not json")
with self.assertRaises(SemanticsError):
self.load_text("[]")
with self.assertRaises(SemanticsError):
self.load_text(
json.dumps(_minimal()).replace('"schema": 1', '"schema": NaN')
)
with tempfile.TemporaryDirectory() as root:
with self.assertRaises(SemanticsError):
load_semantics(Path(root) / "missing.json")
def test_oversized_table_is_rejected_before_parsing(self):
text = json.dumps(_minimal()) + " " * semantics.MAX_TABLE_BYTES
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("exceeds", str(caught.exception))
def test_deeply_nested_table_is_a_semantics_error(self):
# Past the nesting bound, and far past the interpreter's recursion limit.
for depth in (semantics.MAX_NESTING + 1, 100_000):
with self.subTest(depth=depth):
nested = "[" * depth + "]" * depth
text = json.dumps(_minimal()).replace(
'"selectors": {}', '"selectors": ' + nested
)
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("nest", str(caught.exception))
self.assertGreater(semantics.MAX_NESTING, _depth(json.loads(TABLE.read_text())))
# Whichever interpreter limit the parser hits, it is a SemanticsError.
with unittest.mock.patch.object(
semantics.json, "loads", side_effect=RecursionError
):
with self.assertRaises(SemanticsError) as caught:
self.load_doc(_minimal())
self.assertIn("nests deeper", str(caught.exception))
def _depth(value, level=1):
children = value.values() if isinstance(value, dict) else value
if not isinstance(value, (dict, list)):
return level - 1
return max([level] + [_depth(item, level + 1) for item in children])
class SamplerOptionsTest(_TableCase):
"""Task 2.7b: optional sampler lists are input names, or the table fails."""
def test_upstream_verified_ports_are_listed_with_their_source_inputs(self):
# Not installed here: read from the pinned upstream commits the saved
# workflows name (the report lists repository, commit, file and line).
table = load_semantics()
pair = {"positive": "positive", "negative": "negative"}
for name, latent, prompt_overrides, fact_overrides in (
(
"ClownsharKSampler_Beta",
"latent_image",
("options",),
("options", "sigmas"),
),
("KSampler (Efficient)", "latent_image", ("script",), ("script",)),
("ttN KSampler_v2", "latent", ("adv_xyPlot",), ("adv_xyPlot",)),
):
self.assertEqual(
table.samplers[name],
{
"strategy": "ksampler",
"conditioning": pair,
"latent": latent,
"facts": "ksampler",
"prompt_overrides": prompt_overrides,
"fact_overrides": fact_overrides,
},
name,
)
for (name, slot), source in {
("SeedVarianceEnhancer", 0): "conditioning",
("ApplyInstantIDAdvanced", 1): "positive",
("ApplyInstantIDAdvanced", 2): "negative",
}.items():
self.assertEqual(
table.conditioning[(name, slot)],
{"strategy": "passthrough", "input": source},
)
self.assertNotIn(("ApplyInstantIDAdvanced", 0), table.conditioning)
self.assertEqual(
table.selectors["Switch conditioning [Crystools]"],
table.selectors["easy ifElse"],
)
self.assertEqual(
table.encoders[("smZ CLIPTextEncode", 0)],
{
"strategy": "flag-parts",
"inputs": ("text", "text_g", "text_l"),
"params": {"flag": "with_SDXL"},
},
)
for name, source in (
("ttN text", "text"),
("CR Text", "text"),
("CR Prompt Text", "prompt"),
("TextBoxMira", "text"),
):
self.assertEqual(
table.strings[(name, 0)], {"strategy": "literal", "inputs": (source,)}
)
self.assertNotIn((name, 1), table.strings)
def test_flag_parts_needs_a_flag_and_two_inputs(self):
doc = _minimal()
entry = {
"class": "smZ CLIPTextEncode",
"slot": 0,
"strategy": "flag-parts",
"inputs": ["text", "text_g"],
"params": {"flag": "with_SDXL"},
}
doc["encoders"].append(entry)
self.assertEqual(
self.load_doc(doc).encoders[("smZ CLIPTextEncode", 0)]["params"],
{"flag": "with_SDXL"},
)
for change in (
{"params": {}},
{"params": {"flag": "x", "other": "y"}},
{"params": {"flag": ""}},
{"params": None},
{"inputs": ["text"]},
{"compose": True},
):
with self.subTest(change=change):
broken = _minimal()
broken["encoders"].append({**entry, **change})
with self.assertRaises(SemanticsError):
self.load_doc(broken)
broken = _minimal()
broken["encoders"].append(
{key: item for key, item in entry.items() if key != "params"}
)
with self.assertRaises(SemanticsError):
self.load_doc(broken)
def test_prompt_overrides_are_input_names(self):
doc = _minimal()
doc["samplers"]["KSampler"]["prompt_overrides"] = ["wildcard"]
sampler = self.load_doc(doc).samplers["KSampler"]
self.assertEqual(sampler["prompt_overrides"], ("wildcard",))
for value in ([], "wildcard", [""], ["a", "a"], [1]):
with self.subTest(value=value):
doc["samplers"]["KSampler"]["prompt_overrides"] = value
with self.assertRaises(SemanticsError):
self.load_doc(doc)
doc["samplers"]["KSampler"].pop("prompt_overrides")
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "sigmas"]
sampler = self.load_doc(doc).samplers["KSampler"]
self.assertEqual(sampler["fact_overrides"], ("script", "sigmas"))
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "script"]
with self.assertRaises(SemanticsError):
self.load_doc(doc)
doc["samplers"]["KSampler"].pop("fact_overrides")
doc["samplers"]["KSampler"]["overrides"] = ["wildcard"]
self.assertRejected(doc, "unknown field")
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -487,6 +487,115 @@ class TestLibrary(unittest.TestCase):
rotate_history_epoch(self.db.model.get_connection())
self.assertNotEqual(token(), before)
def test_library_items_expose_run_count_and_last_used(self):
import datetime
used = self.save("Used")
never = self.save("Never")
self.assertTrue(self.db.record_prompt_use(used))
self.assertTrue(self.db.record_prompt_use(used, times=2))
legacy = self.save("Legacy naive")
offset = self.save("Offset")
broken = self.save("Broken")
zulu = self.save("Zulu")
# Parse but cannot convert to UTC: past the datetime range either way.
floor = self.save("Floor")
ceiling = self.save("Ceiling")
with self.db.model.get_connection() as connection:
for prompt_id, value in (
(legacy, "2026-10-07 18:39:06"),
(zulu, "2026-10-07T18:39:06Z"),
(floor, "0001-01-01T00:00:00+01:00"),
(ceiling, "9999-12-31T23:59:59-01:00"),
(offset, "2026-10-07T20:39:06.250+02:00"),
(broken, "yesterday"),
):
connection.execute(
"UPDATE prompts SET last_used_at=?,run_count=1 WHERE id=?",
(value, prompt_id),
)
items = {row["id"]: row for row in self.db.list_library()["items"]}
self.assertEqual(items[used]["run_count"], 3)
stamp = datetime.datetime.fromisoformat(items[used]["last_used_at"])
self.assertEqual(stamp.utcoffset(), datetime.timedelta(0))
self.assertLess(
abs(datetime.datetime.now(datetime.timezone.utc) - stamp),
datetime.timedelta(minutes=5),
)
self.assertEqual(items[never]["run_count"], 0)
self.assertIsNone(items[never]["last_used_at"])
self.assertEqual(items[legacy]["last_used_at"], "2026-10-07T18:39:06+00:00")
self.assertEqual(
items[offset]["last_used_at"], "2026-10-07T18:39:06.250000+00:00"
)
self.assertIsNone(items[broken]["last_used_at"])
self.assertEqual(items[zulu]["last_used_at"], "2026-10-07T18:39:06+00:00")
self.assertIsNone(items[floor]["last_used_at"])
self.assertIsNone(items[ceiling]["last_used_at"])
self.assertIsNone(
self.db.get_library_positive(floor)["positive"]["last_used_at"]
)
self.assertEqual(type(items[broken]["run_count"]), int)
detail = self.db.get_library_positive(used)["positive"]
self.assertEqual(detail["run_count"], 3)
self.assertEqual(detail["last_used_at"], items[used]["last_used_at"])
def test_variant_total_matches_filtered_cards(self):
archived = self.db.save_prompt_pair("Cat", "Blur", category="A")
self.db.save_prompt_pair("Cat", "Noise", category="A")
self.db.save_prompt_pair("Cat", "Grain", category="B")
self.db.archive_prompt_pair(archived)
self.db.save_prompt_pair("Dog", "Blur", category="B")
self.save("Legacy")
everything = self.db.list_library()
self.assertEqual(everything["variant_total"], 3)
self.assertEqual(
everything["variant_total"],
sum(row["variant_count"] for row in everything["items"]),
)
cats = self.db.list_library(text="Cat")
self.assertEqual(
(cats["total"], cats["variant_total"]),
(1, 2),
)
self.assertEqual(self.db.list_library(category="B")["variant_total"], 3)
self.assertEqual(self.db.list_library(text="nothing")["variant_total"], 0)
first = self.db.list_library(limit=1)
self.assertEqual(first["variant_total"], 3)
# A later parent is past the cursor's ceiling, like the total.
self.db.save_prompt_pair("Bird", "Blur")
second = self.db.list_library(limit=1, after=first["next_key"])
self.assertEqual(
(second["total"], second["variant_total"]),
(first["total"], 3),
)
def test_edit_token_unchanged_by_usage_fields(self):
positive = self.save("Cat", category="A", tags=["a"], rating=3)
def parent():
return self.db.get_library_positive(positive)["positive"]
before = parent()
listed = self.db.list_library()["items"][0]
self.assertEqual(listed["edit_token"], before["edit_token"])
self.db.record_prompt_use(positive, times=4)
with self.db.model.get_connection() as connection:
connection.execute(
"UPDATE prompts SET last_used_at='2001-01-01T00:00:00+00:00' "
"WHERE id=?",
(positive,),
)
after = parent()
self.assertNotEqual(
(after["run_count"], after["last_used_at"]),
(before["run_count"], before["last_used_at"]),
)
self.assertEqual(after["edit_token"], before["edit_token"])
self.assertEqual(
self.db.list_library()["items"][0]["edit_token"], before["edit_token"]
)
def test_library_snapshot_reads_epoch_and_ceiling_in_one_select_and_ends(self):
self.save("Cat")
connection = self.db.model.get_connection()
@@ -1584,3 +1693,16 @@ class TestLibrary(unittest.TestCase):
)
with self.assertRaisesRegex(ValueError, "does not belong to this positive"):
self.db.list_library_images(positive, pair_id=foreign, archive="all")
class DisplayTitleUnits(unittest.TestCase):
"""The first-line title is cut like the Library's JavaScript title()."""
def test_title_counts_utf16_units_and_never_splits_a_surrogate_pair(self):
from database.library import display_title
self.assertEqual(display_title((), " short line \nsecond"), "short line")
self.assertEqual(display_title((), "a" * 80), "a" * 80)
self.assertEqual(display_title((), "a" * 81), "a" * 80 + "…")
self.assertEqual(display_title((), "\U0001f600" * 80), "\U0001f600" * 40 + "…")
self.assertEqual(display_title((), "a" * 79 + "\U0001f600b"), "a" * 79 + "…")
+67 -9
View File
@@ -17,6 +17,14 @@ def graph(positive="cat", negative="blur"):
}
def sampled_graph(positive="cat", negative="blur"):
"""A KSampler with a model link, as real workflows have."""
g = graph(positive, negative)
g["4"] = {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}
g["3"]["inputs"]["model"] = ["4", 0]
return g
class TestPairScan(AdminAPITestCase):
def test_pair_scan_repeated_is_unchanged_and_empty_negative_is_supported(self):
path = self.output_dir / "pair.png"
@@ -28,16 +36,66 @@ class TestPairScan(AdminAPITestCase):
self.assertEqual(pairs[0]["negative_text"], "")
self.assertEqual(pairs[0]["run_count"], 0)
def test_ambiguous_sampler_scan_does_not_save_a_guess(self):
g = graph()
g["4"] = {**g["3"]}
meta = {"prompt": json.dumps(g)}
self.assertEqual(
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
"ambiguous",
)
def _scanned(self, name, prompt_graph):
meta = {"prompt": json.dumps(prompt_graph)}
return self.api._ingest_scanned_file(self.output_dir / name, meta)
def _texts(self):
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
def test_samplers_sharing_one_prompt_link_that_prompt(self):
g = sampled_graph()
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("multi.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
# The prompt the inspector and the legacy panels show for this file.
g = sampled_graph()
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
self.assertEqual(self._scanned("different.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_an_unsupported_pass_through_still_links_the_traced_prompt(self):
g = sampled_graph()
g["7"] = {
"class_type": "MyConditioningTweak",
"inputs": {"conditioning": ["1", 0]},
}
g["3"]["inputs"]["positive"] = ["7", 0]
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("tweak.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_a_graph_without_a_traceable_prompt_saves_nothing(self):
g = sampled_graph()
g["5"] = {**g["3"]}
for sampler in ("3", "5"):
g[sampler]["inputs"]["positive"] = ["9", 0]
g["9"] = {
"class_type": "BasicGuider",
"inputs": {"width": 2048, "height": 2048},
}
self.assertEqual(self._scanned("untraceable.png", g), "ambiguous")
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
self.assertEqual(self._texts(), [])
def test_numeric_widgets_are_never_stored_as_a_prompt(self):
# The pre-redesign scan stored "2048" from a resolution widget here.
g = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": ["8", 0]}},
"8": {"class_type": "DisplayText", "inputs": {"text_size": 2048}},
"3": {
"class_type": "SamplerCustomAdvanced",
"inputs": {"guider": ["4", 0], "sigmas": ["5", 0]},
},
"4": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
}
self.assertIn(
self._scanned("numbers.png", g), ("ambiguous", "unresolved", "found")
)
self.assertEqual(self._texts(), [])
def test_batch_exposes_pair_counts_and_actual_unchanged_status(self):
meta = {"parameters": "cat\nNegative prompt: blur\nSteps: 20, Sampler: Euler"}
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
"""The concept fixture files carry the concept inspector facts (app.js:400-414)."""
import json
import tempfile
import unittest
from pathlib import Path
from PIL import Image
try:
from tests.open_handles import assert_closed
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed
from tests.browser.workspace_concept_fixture import (
CONCEPT_MODEL,
CONCEPT_PROMPTS,
write_concept_png,
)
from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file
class TestWorkspaceConceptFixture(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.addCleanup(assert_closed, self, self.tmp.name)
def write(self, prompt_index, round_index):
prompt = CONCEPT_PROMPTS[prompt_index]
path = Path(self.tmp.name) / f"{prompt.id}_{round_index + 1:05}.png"
image = write_concept_png(path, prompt_index, round_index)
return path, image
def test_each_image_carries_execution_evidence_and_the_concept_facts(self):
for prompt_index, round_index in ((0, 0), (0, 2), (3, 1), (5, 3)):
with self.subTest(prompt=prompt_index, round=round_index):
prompt = CONCEPT_PROMPTS[prompt_index]
path, image = self.write(prompt_index, round_index)
negative = prompt.negatives[round_index % len(prompt.negatives)][1]
inspection = read_inspection_file(path)
evidence = inspect_prompt_evidence(inspection.metadata)
# D21: "execution" is the source the panel badges "Execution recorded".
self.assertEqual(
evidence["prompts"]["positive"],
{
"status": "resolved",
"text": prompt.positive,
"source": "execution",
},
)
self.assertEqual(evidence["prompts"]["negative"]["text"], negative)
self.assertEqual(evidence["prompts"]["negative"]["source"], "execution")
graph = json.loads(inspection.metadata["prompt"])
sampler = next(
node["inputs"]
for node in graph.values()
if node["class_type"] == "KSampler"
)
self.assertEqual(
sampler["seed"], 42001 + prompt_index * 100 + round_index
)
self.assertEqual(sampler["seed"], image["seed"])
self.assertEqual(
(sampler["sampler_name"], sampler["scheduler"]),
("dpmpp_2m", "karras"),
)
self.assertEqual((sampler["steps"], sampler["cfg"]), (28, 6.5))
loader = graph[sampler["model"][0]]
self.assertEqual(loader["class_type"], "CheckpointLoaderSimple")
self.assertEqual(loader["inputs"]["ckpt_name"], CONCEPT_MODEL)
self.assertEqual(CONCEPT_MODEL, "illustrative-model.safetensors")
with Image.open(path) as source:
self.assertEqual(source.size, (image["width"], image["height"]))
def test_concept_dimensions_follow_the_prompt_index(self):
expected = [(1536, 1152), (1024, 1536), (1280, 1280)]
for prompt_index, size in enumerate(expected):
_, image = self.write(prompt_index, 1)
self.assertEqual((image["width"], image["height"]), size)
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { lintOverlay } from '../../tools/vendor/sync-concept-css.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
const script = join(root, 'tools/vendor/sync-concept-css.mjs');
const concept = join(root, 'docs/concepts/workspace/style.css');
const copy = join(root, 'web/js/workspace/concept.css');
const sha = path => createHash('sha256').update(readFileSync(path)).digest('hex');
function walk(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const path = join(dir, entry.name);
return entry.isDirectory() ? walk(path) : [path];
});
}
test('concept copy is byte-identical', () => {
assert.ok(existsSync(copy), 'web/js/workspace/concept.css is missing; run sync-concept-css.mjs --write');
assert.equal(sha(copy), sha(concept));
const result = spawnSync(process.execPath, [script, '--check'], { cwd: root, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
});
test('check fails on a one-byte drift', () => {
const temp = mkdtempSync(join(tmpdir(), 'concept-css-'));
try {
mkdirSync(join(temp, 'docs/concepts/workspace'), { recursive: true });
mkdirSync(join(temp, 'web/js/workspace'), { recursive: true });
copyFileSync(concept, join(temp, 'docs/concepts/workspace/style.css'));
writeFileSync(join(temp, 'web/js/workspace/concept.css'), Buffer.concat([readFileSync(concept), Buffer.from(' ')]));
const run = () => spawnSync(process.execPath, [script, '--check', '--root', temp], { encoding: 'utf8' });
const drifted = run();
assert.equal(drifted.status, 1);
assert.match(drifted.stderr, /drift/i);
assert.equal(spawnSync(process.execPath, [script, '--write', '--root', temp]).status, 0);
assert.equal(run().status, 0);
} finally { rmSync(temp, { recursive: true, force: true }); }
});
test('overlay lint requires a decision marker on concept selectors', () => {
const conceptCss = '.card, .row { color: red; }\n@media (max-width: 700px) { .card .title { margin: 0; } }\n';
assert.deepEqual(lintOverlay(conceptCss, '.error-row { color: red; }').violations, []);
assert.equal(lintOverlay(conceptCss, '.x, .card { color: blue; }').violations.length, 1);
assert.equal(lintOverlay(conceptCss, '@media (max-width: 700px) { .card .title { margin: 1px; } }').violations.length, 1);
assert.deepEqual(lintOverlay(conceptCss, '/* D12 */\n.card { color: blue; }').violations, []);
assert.deepEqual(lintOverlay(conceptCss, '.card { /* D3 */ color: blue; }').violations, []);
assert.equal(lintOverlay(conceptCss, '/* tweak */ .card { color: blue; }').violations.length, 1);
assert.equal(lintOverlay(conceptCss, '/* D1 */ .card { color: blue; }\n.row { gap: 1px; }').violations.length, 1);
});
test('overlay lint accepts only this spec\'s (#249) decision numbers', () => {
const conceptCss = '.card { color: red; }\n';
assert.deepEqual(lintOverlay(conceptCss, '/* D12 of #249 */ .card { color: blue; }').violations, []);
assert.deepEqual(lintOverlay(conceptCss, '/* #249 D3: reason */ .card { color: blue; }').violations, []);
// Another issue's decision numbers are not #249 rulings, so they never mark a concept override.
assert.equal(lintOverlay(conceptCss, '/* D5 (#245) */ .card { color: blue; }').violations.length, 1);
assert.equal(lintOverlay(conceptCss, '/* D2 of #240 and #249 */ .card { color: blue; }').violations.length, 1);
assert.equal(lintOverlay(conceptCss, '/* #245 decision 5 */ .card { color: blue; }').violations.length, 1);
assert.equal(lintOverlay(conceptCss, '/* AD5 */ .card { color: blue; }').violations.length, 1);
});
test('overlay rules on concept selectors carry a decision marker', () => {
const overlay = join(root, 'web/js/workspace/workspace.css');
assert.ok(existsSync(overlay), 'web/js/workspace/workspace.css is missing');
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(overlay, 'utf8'));
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.
test('kept page stylesheets mark their concept-selector overrides', () => {
for (const name of ['images.css', 'library.css', 'inspector.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']) {
assert.equal(css.includes(retired), false, `${retired} is retired`);
}
});
test('only workspace.html loads workspace CSS', () => {
const offenders = walk(join(root, 'web'))
.filter(path => /\.(html|js|mjs)$/.test(path) && !path.includes('/lib/') && !path.includes('/shared/'))
.filter(path => /\b(concept|workspace)\.css\b/.test(readFileSync(path, 'utf8')))
.map(path => path.slice(root.length + 1));
assert.ok(offenders.every(path => path === 'web/workspace.html'), `unexpected consumers: ${offenders.join(', ')}`);
});
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept) with the page stylesheets that their own PRs
// retire, then the production overlay last.
test('workspace.html loads controls, viewer, concept, page CSS and the overlay in order', () => {
const html = readFileSync(join(root, 'web/workspace.html'), 'utf8');
const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map(match => match[1]);
assert.deepEqual(hrefs, [
'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']) {
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
}
});
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
// Mount-aware URLs (#164): Prompt Manager runs behind reverse proxies with a path prefix, so every src, href or
// new URL() the workspace builds from a "/prompt_manager/..." literal must go through resolveUrl (or the api client).
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
const SINK = /(\.(?:src|href)\s*=|setAttribute\(\s*['"](?:src|href)['"]\s*,|new URL\()/g;
const RAW = /['"`]\/prompt_manager\//;
export function rawUrlSinks(source) {
const found = [];
source.split('\n').forEach((line, index) => {
for (const match of line.matchAll(SINK)) {
const rest = line.slice(match.index + match[0].length);
const literal = rest.search(RAW);
if (literal !== -1 && !/\bresolve(?:Url)?\s*\(/.test(rest.slice(0, literal))) found.push(`${index + 1}: ${line.trim()}`);
}
});
return found;
}
test('the scanner flags raw Prompt Manager URLs in src, href and new URL()', () => {
assert.equal(rawUrlSinks("img.src = '/prompt_manager/images/serve/a.png';").length, 1);
assert.equal(rawUrlSinks('link.href = `/prompt_manager/admin`;').length, 1);
assert.equal(rawUrlSinks("node.setAttribute('href', '/prompt_manager/gallery');").length, 1);
assert.equal(rawUrlSinks("const url = new URL('/prompt_manager/x', location.href);").length, 1);
assert.deepEqual(rawUrlSinks("img.src = resolveUrl('/prompt_manager/images/serve/a.png');"), []);
assert.deepEqual(rawUrlSinks('const u = new URL(resolve(`/prompt_manager/history/${id}`), location.href);'), []);
assert.deepEqual(rawUrlSinks("link.href = resolveUrl(route.href);"), []);
});
test('workspace modules build Prompt Manager src, href and URLs only through resolveUrl', () => {
const dir = join(root, 'web/js/workspace');
const offenders = readdirSync(dir).filter(name => name.endsWith('.mjs'))
.flatMap(name => rawUrlSinks(readFileSync(join(dir, name), 'utf8')).map(hit => `${name}:${hit}`));
assert.deepEqual(offenders, []);
});
+50 -2
View File
@@ -19,7 +19,7 @@
},
"comfy-metadata.js": {
"lines": 97,
"branches": 91,
"branches": 92,
"functions": 98,
"statements": 97
},
@@ -161,6 +161,12 @@
"functions": 88,
"statements": 95
},
"workspace/heading.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/history-media.mjs": {
"lines": 98,
"branches": 86,
@@ -191,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,
@@ -205,7 +229,7 @@
},
"workspace/index.mjs": {
"lines": 98,
"branches": 95,
"branches": 96,
"functions": 98,
"statements": 98
},
@@ -293,6 +317,24 @@
"functions": 98,
"statements": 98
},
"workspace/toast.mjs": {
"lines": 98,
"branches": 64,
"functions": 98,
"statements": 98
},
"workspace/toolbar.mjs": {
"lines": 94,
"branches": 93,
"functions": 98,
"statements": 94
},
"workspace/topbar.mjs": {
"lines": 98,
"branches": 98,
"functions": 83,
"statements": 98
},
"workspace/viewer-state.mjs": {
"lines": 98,
"branches": 95,
@@ -316,5 +358,11 @@
"branches": 91,
"functions": 93,
"statements": 98
},
"workspace/workspace-dialogs.mjs": {
"lines": 97,
"branches": 95,
"functions": 98,
"statements": 97
}
}
+2 -1
View File
@@ -5,7 +5,8 @@
"description": "Maintainer-only tools for reproducible browser dependencies",
"scripts": {
"sync": "node vendor-viewerjs.mjs",
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check && node sync-concept-css.mjs --check",
"sync:concept": "node sync-concept-css.mjs --write",
"sync:toolkit": "node vendor-toolkit.mjs",
"update": "node update-viewerjs.mjs",
"test": "node --test ../../tests/vendor/*.test.mjs",
+130
View File
@@ -0,0 +1,130 @@
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repositoryRoot } from './vendor-viewerjs.mjs';
const CONCEPT = 'docs/concepts/workspace/style.css';
const COPY = 'web/js/workspace/concept.css';
const MARKER = /\/\*[^*]*\bD\d+\b[^*]*\*\//;
const SPEC_ISSUE = '#249';
// A decision marker names one of this spec's (#249) D-numbers. A comment that cites any other issue is that issue's
// ruling (for example #245's own D5), so it never marks a concept override here.
function isDecisionMarker(comment) {
return MARKER.test(comment) && (comment.match(/#\d+\b/g) ?? []).every(issue => issue === SPEC_ISSUE);
}
// Minimal tokenizer: walks top-level rules and the bodies of @media/@supports,
// returning each rule's normalized selectors and the comments attached to it
// (those before its selector and those inside its block).
function parseRules(css) {
const rules = [];
let index = 0;
const length = css.length;
function readBlock(start) {
let depth = 0;
let cursor = start;
for (; cursor < length; cursor += 1) {
if (css.startsWith('/*', cursor)) { cursor = commentEnd(cursor) - 1; continue; }
const char = css[cursor];
if (char === '"' || char === "'") { cursor = stringEnd(cursor) - 1; continue; }
if (char === '{') depth += 1;
else if (char === '}') { depth -= 1; if (depth === 0) return cursor; }
}
return length - 1;
}
function commentEnd(at) { const end = css.indexOf('*/', at + 2); return end === -1 ? length : end + 2; }
function stringEnd(at) {
for (let cursor = at + 1; cursor < length; cursor += 1) {
if (css[cursor] === '\\') cursor += 1;
else if (css[cursor] === css[at]) return cursor + 1;
}
return length;
}
function walk(from, to) {
let cursor = from;
let comments = [];
let prelude = '';
while (cursor < to) {
if (css.startsWith('/*', cursor)) {
const end = commentEnd(cursor);
comments.push(css.slice(cursor, end));
cursor = end;
continue;
}
const char = css[cursor];
if (char === ';') { prelude = ''; comments = []; cursor += 1; continue; }
if (char === '{') {
const close = readBlock(cursor);
const head = prelude.trim();
const body = css.slice(cursor + 1, close);
if (/^@(media|supports|layer)\b/i.test(head)) walk(cursor + 1, close);
else if (!head.startsWith('@')) {
rules.push({ selectors: splitSelectors(head), comments: [...comments, ...(body.match(/\/\*[\s\S]*?\*\//g) ?? [])] });
}
prelude = ''; comments = []; cursor = close + 1;
continue;
}
prelude += char;
cursor += 1;
}
}
walk(index, length);
return rules;
}
function splitSelectors(head) {
const out = [];
let depth = 0;
let current = '';
for (const char of head) {
if (char === '(' || char === '[') depth += 1;
else if (char === ')' || char === ']') depth -= 1;
if (char === ',' && depth === 0) { out.push(current); current = ''; } else current += char;
}
out.push(current);
return out.map(selector => selector.replace(/\s+/g, ' ').trim()).filter(Boolean);
}
export function lintOverlay(conceptCss, overlayCss) {
const owned = new Set(parseRules(conceptCss).flatMap(rule => rule.selectors));
const violations = [];
for (const rule of parseRules(overlayCss)) {
const shared = rule.selectors.filter(selector => owned.has(selector));
if (shared.length && !rule.comments.some(isDecisionMarker)) {
violations.push(`${shared.join(', ')}: overlay rule on a concept selector needs a /* D<n> */ (#249) decision marker`);
}
}
return { violations };
}
export function syncConceptCss({ root = repositoryRoot, check = false } = {}) {
const source = readFileSync(join(root, CONCEPT));
const target = join(root, COPY);
if (check) {
let current = null;
try { current = readFileSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
if (!current) throw new Error(`Concept stylesheet drift: ${COPY} is missing; run sync-concept-css.mjs --write`);
if (!current.equals(source)) {
let offset = 0;
while (offset < source.length && offset < current.length && source[offset] === current[offset]) offset += 1;
throw new Error(`Concept stylesheet drift: ${COPY} (${current.length} bytes) differs from ${CONCEPT} (${source.length} bytes) at byte ${offset}; run sync-concept-css.mjs --write`);
}
} else {
mkdirSync(join(root, 'web/js/workspace'), { recursive: true });
writeFileSync(target, source);
}
return source.length;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const args = process.argv.slice(2);
const rootAt = args.indexOf('--root');
const root = rootAt === -1 ? repositoryRoot : resolve(args[rootAt + 1]);
const mode = args.filter((arg, at) => arg.startsWith('--') && arg !== '--root' && !(rootAt !== -1 && at === rootAt + 1));
if (mode.length !== 1 || !['--write', '--check'].includes(mode[0])) throw new Error('Usage: sync-concept-css.mjs --write | --check [--root <dir>]');
const bytes = syncConceptCss({ root, check: mode[0] === '--check' });
console.log(`Concept stylesheet ${mode[0] === '--check' ? 'verified' : 'written'} (${bytes} bytes)`);
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+115
View File
@@ -0,0 +1,115 @@
"""Workspace card facts from the metadata records a header open already loaded.
The card shows what the inspector shows, so it reads exactly what the
inspector's browser parser reads: each ``prompt``/``workflow`` record through
the inspection projection (``project_graph``: strict JSON, the depth and node
bounds, big integers and decimals as text), and only graphs it parsed, through
the shared parser (``extract_generation_params``, the Python twin of the
browser parser the inspector fills from since #251). No separate seed
heuristic, no graph resolution and no extra file reads. One parse feeds both
the seed and the positive that confirms a first-line card title.
"""
import re
from collections.abc import Mapping
from .generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
)
from .inspection_response import project_graph
__all__ = [
"MAX_RECORD_BYTES",
"MAX_SEED_DIGITS",
"card_seed",
"header_params",
"parsed_positive",
]
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
MAX_RECORD_BYTES = 2 * 1024 * 1024
_RECORD_KEYS = ("prompt", "workflow")
# A digit-string seed has at most this many digits (2**64 has 20). Longer text
# is no seed, and never reaches int(), whose max-str-digits guard raises.
MAX_SEED_DIGITS = 20
_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS)
def _utf8_size_over(text: str, limit: int) -> bool:
"""True when ``text`` is over ``limit`` UTF-8 bytes, encoding only if unclear.
A code point takes one to four bytes, so most records are decided from
their length alone without copying them.
"""
if len(text) > limit:
return True
if len(text) * 4 <= limit:
return False
return len(text.encode("utf-8", "surrogatepass")) > limit
def header_params(info) -> dict | None:
"""The shared parser's result for the inspector's view of ``info``'s records.
``info`` is a Pillow ``Image.info`` (or the inspection read's metadata).
A record the projection does not parse is left out, as the inspector
leaves it out. None when no record parses, a record is not text or is
over ``MAX_RECORD_BYTES``, or the parser cannot use the graphs.
"""
if not isinstance(info, Mapping):
return None
graphs = {}
for key in _RECORD_KEYS:
text = info.get(key)
if text is None:
continue
if not isinstance(text, str) or _utf8_size_over(text, MAX_RECORD_BYTES):
return None
graph = project_graph(text, key)
if graph["status"] == "parsed":
graphs[key] = graph["value"]
if not graphs:
return None
try:
return extract_generation_params(graphs)
except (GenerationParamsError, RecursionError):
# Defence in depth: a parser failure gives no card facts, never a 500.
return None
def card_seed(params: dict | None) -> str | None:
"""The parsed seed as a decimal string, else None.
Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and
ComfyUI's randomized seeds are often that large. The projection carries
integers beyond 2**53 as their exact digits, so a digit string is the
recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS``
digits. A placeholder, other or longer text, a boolean or a decimal gives
None.
"""
if not isinstance(params, Mapping):
return None
value = params.get("seed")
if isinstance(value, bool):
return None
try:
if isinstance(value, int):
return str(value)
if isinstance(value, str) and _INTEGER.match(value):
return str(int(value))
except ValueError:
# Python's integer string conversion limit: no seed, never a 500.
return None
return None
def parsed_positive(params: dict | None) -> str | None:
"""The parsed positive prompt text, or None for the placeholder or blank."""
if not isinstance(params, Mapping):
return None
text = params.get("positivePrompt")
if not isinstance(text, str) or text == PLACEHOLDERS["positivePrompt"]:
return None
return text if text.strip() else None
+966
View File
@@ -0,0 +1,966 @@
"""Generation parameters traced from ComfyUI metadata: the browser parser in Python.
A port of ``extractGenerationParams`` in ``web/js/comfy-metadata.js``, the parser
behind the gallery, admin and metadata pages. For the same records it returns
what that function returns; ``tests/test_generation_params_js_parity.py``
enforces it against the JavaScript itself.
It traces the API ``prompt`` graph from the sampler: the positive and negative
inputs back to their text nodes, the model link back to a loader, and the
sampler settings, following primitives and pass-through nodes. Fields the API
graph cannot give are read from the UI ``workflow`` graph.
The result depends on JavaScript value semantics, so those are reproduced
explicitly: property order, truthiness, ``String()``, ``trim()``, strict
equality, ``Map``/``Set`` keys, and the inputs on which the JavaScript throws
(raised here as ``GenerationParamsError``). The recursive tracing runs on an
explicit stack, so a crafted graph cannot exhaust the Python stack.
"""
import json
import math
import re
from types import MappingProxyType
__all__ = [
"PLACEHOLDERS",
"GenerationParamsError",
"extract_generation_params",
"extract_from_records",
"parse_js_json",
]
PLACEHOLDERS = MappingProxyType(
{
"positivePrompt": "No prompt found",
"negativePrompt": "No negative prompt found",
"checkpoint": "Unknown",
"steps": "Unknown",
"cfgScale": "Unknown",
"sampler": "Unknown",
"seed": "Unknown",
}
)
# Upper bound on nodes expanded per lookup; crafted files can contain any graph.
MAX_VISITS = 500
MODEL_NAME_KEYS = ("ckpt_name", "unet_name", "model_name", "gguf_name")
SEED_CONTROL_VALUES = frozenset(("fixed", "increment", "decrement", "randomize"))
PASS_THROUGH_TYPES = frozenset(("Reroute", "GetNode", "SetNode"))
MODE_MUTED = 2
MODE_BYPASSED = 4
# Widget order for core nodes, used when a workflow carries no widget names.
PROMPT_MANAGER_WIDGETS = (
"text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
)
WIDGET_POSITIONS = {
"KSampler": ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"),
"KSamplerAdvanced": (
"add_noise",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"scheduler",
"start_at_step",
"end_at_step",
"return_with_leftover_noise",
),
"CLIPTextEncode": ("text",),
"PromptManager": PROMPT_MANAGER_WIDGETS,
"PromptManagerText": PROMPT_MANAGER_WIDGETS,
"PromptManagerFocused": PROMPT_MANAGER_WIDGETS,
"PromptManagerSplit": PROMPT_MANAGER_WIDGETS,
"PromptManagerPair": (
"text",
"negative_text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
),
"CheckpointLoader": ("config_name", "ckpt_name"),
"UNETLoader": ("unet_name", "weight_dtype"),
}
# JavaScript regular expressions without the ``u`` flag fold case in ASCII only.
_FLAGS = re.IGNORECASE | re.ASCII
TEXT_KEYS = re.compile(r"(text|string|prompt|value)(_?[a-z0-9]+)?", _FLAGS)
_PROMPT_MANAGER = re.compile(r"promptmanager", _FLAGS)
_KSAMPLER = re.compile(r"ksampler", _FLAGS)
_CHECKPOINT_LOADER = re.compile(r"checkpointloader", _FLAGS)
_UNET = re.compile(r"unet", _FLAGS)
_SEED_NAME = re.compile(r"seed\Z")
_ARRAY_INDEX = re.compile(r"(?:0|[1-9][0-9]*)\Z")
_MAX_ARRAY_INDEX = 2**32 - 2
_MAX_SAFE_INTEGER = 2**53
# String.prototype.trim() removes WhiteSpace and LineTerminator code points.
_JS_WHITESPACE = "\t\n\v\f\r          " "    

   "
# Properties every object literal inherits; reading one yields a function or object.
_OBJECT_PROTOTYPE_KEYS = frozenset(
(
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toLocaleString",
"toString",
"valueOf",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
)
)
class GenerationParamsError(ValueError):
"""The browser parser throws on this input, so there is no result."""
class _Undefined:
"""JavaScript ``undefined``: a missing property, distinct from JSON ``null``."""
__slots__ = ()
def __repr__(self):
return "undefined"
UNDEFINED = _Undefined()
# --- JavaScript value semantics -------------------------------------------------
def _is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _nullish(value):
return value is None or value is UNDEFINED
def _truthy(value):
if value is None or value is UNDEFINED or value is False:
return False
if _is_number(value):
return value != 0 and not (isinstance(value, float) and math.isnan(value))
if isinstance(value, str):
return value != ""
return True
def _typeof(value):
if value is UNDEFINED:
return "undefined"
if isinstance(value, bool):
return "boolean"
if _is_number(value):
return "number"
if isinstance(value, str):
return "string"
return "object"
def _number_string(value):
"""``String(number)`` for a double, per the ECMAScript Number::toString rules."""
if isinstance(value, int):
return str(value)
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "Infinity" if value > 0 else "-Infinity"
if value == 0:
return "0"
sign = "-" if value < 0 else ""
text = repr(abs(value))
mantissa, _, exponent = text.partition("e")
whole, _, fraction = mantissa.partition(".")
raw = whole + fraction
leading = len(raw) - len(raw.lstrip("0"))
digits = raw.lstrip("0").rstrip("0")
point = len(whole) - leading + int(exponent or 0)
count = len(digits)
if count <= point <= 21:
body = digits + "0" * (point - count)
elif 0 < point <= 21:
body = digits[:point] + "." + digits[point:]
elif -6 < point <= 0:
body = "0." + "0" * -point + digits
else:
power = point - 1
mark = ("+" if power >= 0 else "-") + str(abs(power))
body = (digits if count == 1 else digits[0] + "." + digits[1:]) + "e" + mark
return sign + body
def _js_string(value):
"""``String(value)`` for any JSON-derived value."""
if value is UNDEFINED:
return "undefined"
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if _is_number(value):
return _number_string(value)
if isinstance(value, str):
return value
if isinstance(value, list):
return _array_string(value)
return "[object Object]"
def _array_string(value):
"""``String(array)``: ``join(",")`` applied to nested arrays, on an explicit stack.
null and undefined elements join as "". A crafted file can nest arrays far
deeper than the Python stack allows, so this never recurses.
"""
out = []
stack = [(value, 0)]
while stack:
items, index = stack.pop()
if index == len(items):
continue
if index:
out.append(",")
stack.append((items, index + 1))
item = items[index]
if isinstance(item, list):
stack.append((item, 0))
elif not _nullish(item):
out.append(_js_string(item))
return "".join(out)
def _key(value):
return value if isinstance(value, str) else _js_string(value)
def _index(key):
"""The array index a property key names, or None."""
if (
_is_number(key)
and not isinstance(key, bool)
and float(key).is_integer()
and key >= 0
):
number = int(key)
return number if number <= _MAX_ARRAY_INDEX else None
text = _key(key)
if _ARRAY_INDEX.match(text) and int(text) <= _MAX_ARRAY_INDEX:
return int(text)
return None
def _get(obj, key):
"""Property read ``obj[key]``; reading from null or undefined throws."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError(f"cannot read {key!r} of {_js_string(obj)}")
if isinstance(obj, dict):
return obj.get(_key(key), UNDEFINED)
if isinstance(obj, (list, str)):
if key == "length":
return len(obj)
index = _index(key)
return obj[index] if index is not None and index < len(obj) else UNDEFINED
return UNDEFINED
def _has(obj, key):
"""``key in obj``; throws on primitives."""
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, list):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
raise GenerationParamsError(f"cannot use 'in' on {_typeof(obj)}")
def _own(obj, key):
"""``Object.prototype.hasOwnProperty.call(obj, key)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("hasOwnProperty on null or undefined")
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, (list, str)):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
return False
def _ordered_keys(obj):
"""Own property order: array indexes ascending, then other keys as inserted."""
indexes = sorted(
(k for k in obj if _ARRAY_INDEX.match(k) and int(k) <= _MAX_ARRAY_INDEX),
key=int,
)
marked = set(indexes)
return indexes + [k for k in obj if k not in marked]
def _entries(obj):
"""``Object.entries(obj)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("Object.entries of null or undefined")
if isinstance(obj, dict):
return [(k, obj[k]) for k in _ordered_keys(obj)]
if isinstance(obj, (list, str)):
return [(str(i), item) for i, item in enumerate(obj)]
return []
def _values(obj):
"""``Object.values(obj)``."""
return [value for _, value in _entries(obj)]
def _strict_equal(left, right):
"""``left === right``."""
if left is UNDEFINED or right is UNDEFINED or left is None or right is None:
return left is right
if isinstance(left, bool) or isinstance(right, bool):
return isinstance(left, bool) and isinstance(right, bool) and left == right
if _is_number(left) and _is_number(right):
return left == right
if isinstance(left, str) and isinstance(right, str):
return left == right
return left is right
def _same_value_key(value):
"""A hashable key with ``Map``/``Set`` (SameValueZero) equality."""
if value is UNDEFINED:
return ("undefined", None)
if value is None:
return ("null", None)
if isinstance(value, bool):
return ("boolean", value)
if _is_number(value):
return (
("nan", None)
if isinstance(value, float) and math.isnan(value)
else ("number", value)
)
if isinstance(value, str):
return ("string", value)
return ("object", id(value))
def _trim(text):
return text.strip(_JS_WHITESPACE)
def _first_truthy(*values):
"""``a || b || ...``: the first truthy operand, else the last."""
for value in values:
if _truthy(value):
return value
return values[-1]
def _and_chain(obj, *steps):
"""``a && a[k1] && a[k1][k2] ...``: stops at the first falsy operand."""
value = obj
for step in steps:
if not _truthy(value):
return value
value = _get(value, step)
return value
def _coalesce(value, fallback):
"""``value ?? fallback()``; the fallback runs only when needed."""
return fallback() if _nullish(value) else value
def _array(value):
"""A value used with an Array method; anything else throws."""
if isinstance(value, list):
return value
raise GenerationParamsError(f"{_typeof(value)} has no array methods")
def _iterate(value):
"""``for (const x of value)``: arrays and strings iterate, other values throw."""
if isinstance(value, (list, str)):
return value
raise GenerationParamsError(f"{_typeof(value)} is not iterable")
def _set(obj, key, value):
"""``obj[key] = value`` on an object literal (``__proto__`` is not an own key)."""
name = _key(key)
if name == "__proto__" and name not in obj:
return
obj[name] = value
def _matches(pattern, value):
"""``pattern.test(value || "")``."""
return pattern.search(_js_string(value if _truthy(value) else "")) is not None
def _run(generator):
"""Drive generator-based recursion on an explicit stack, not the call stack."""
stack = [generator]
value = None
while stack:
try:
request = stack[-1].send(value)
except StopIteration as done:
stack.pop()
value = done.value
continue
stack.append(request)
value = None
return value
# --- Tracing ---------------------------------------------------------------------
def _is_link(value):
return (
isinstance(value, list)
and len(value) == 2
and (isinstance(value[0], str) or _is_number(value[0]))
and _is_number(value[1])
)
def _is_sampler(node):
"""A KSampler or a guider (CFGGuider); the model link excludes pass-throughs."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "positive"))
and _is_link(_get(inputs, "negative"))
and _is_link(_get(inputs, "model"))
)
def _is_custom_sampler(node):
"""SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "guider"))
and _is_link(_get(inputs, "sigmas"))
)
def _workflow_nodes(workflow):
if not _truthy(workflow):
return []
nodes = _get(workflow, "nodes")
return nodes if isinstance(nodes, list) else []
def _widgets_of(node):
values = _get(node, "widgets_values")
return values if isinstance(values, list) else []
class _Context:
"""The graph being traced and recovered PromptManager widget text."""
__slots__ = ("graph", "widget_text")
def __init__(self, graph, widget_text):
self.graph = graph
self.widget_text = widget_text
def _create_context(graph, workflow):
"""Recover PromptManager text overwritten by the pre-3.2.3 SaveImage patch.
The signature is two or more PromptManager nodes with identical API text
whose workflow widgets differ; only those get their widget text back.
Otherwise the API graph is what actually ran and always wins.
"""
widgets = {}
for node in _workflow_nodes(workflow):
text = _get(_widgets_of(node), 0)
node_type = _get(node, "type")
if (
not _strict_equal(node_type, "PromptManagerPair")
and _matches(_PROMPT_MANAGER, node_type)
and isinstance(text, str)
):
widgets[_js_string(_get(node, "id"))] = text
by_api_text = {}
for node_id in widgets:
api_text = _and_chain(graph, node_id, "inputs", "text")
if isinstance(api_text, str):
by_api_text.setdefault(api_text, []).append(node_id)
widget_text = {}
for ids in by_api_text.values():
if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1:
for node_id in ids:
widget_text[node_id] = widgets[node_id]
return _Context(graph, widget_text)
def _enter(context, link, seen):
"""``(node_id, node)`` for a link; None when unresolved or already expanded."""
if not _is_link(link) or len(seen) >= MAX_VISITS:
return None
node_id = _js_string(link[0])
node = _get(context.graph, node_id)
key = f"{node_id}:{_js_string(link[1])}"
if not _truthy(node) or not _truthy(_get(node, "inputs")) or key in seen:
return None
seen.add(key)
return node_id, node
def _resolve_scalar(context, value, name, seen):
"""A scalar input, following a link; an input named like the field wins."""
if not _is_link(value):
return value
entered = _enter(context, value, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
if _truthy(name) and _own(inputs, name):
return (yield _resolve_scalar(context, _get(inputs, name), name, seen))
for item in _values(inputs):
resolved = yield _resolve_scalar(context, item, name, seen)
if resolved is not UNDEFINED and _typeof(resolved) != "object":
return resolved
return UNDEFINED
def _resolve_linked_input(context, link, keys):
"""The first of ``keys`` on the node a link points to, resolving further links."""
seen = set()
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in keys if _has(inputs, k)), None)
if key is None:
return UNDEFINED
return (yield _resolve_scalar(context, _get(inputs, key), key, seen))
def _resolve_string(context, value, seen):
"""A string input; text-producing nodes may be chained (concat, primitives)."""
if isinstance(value, str):
return value
entered = _enter(context, value, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(value[1], 3):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(value[1], 2):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
delimiter = _get(inputs, "delimiter")
if not isinstance(delimiter, str):
delimiter = " "
parts = []
for key, item in _entries(inputs):
if TEXT_KEYS.fullmatch(key):
parts.append((yield _resolve_string(context, item, seen)))
return delimiter.join(part for part in parts if _trim(part))
def _compose_node_text(context, node_id, node, seen):
"""An encoder's text, joined like PromptManager: prepend + text + append."""
inputs = _get(node, "inputs")
if node_id in context.widget_text:
raw = context.widget_text[node_id]
else:
raw = yield _resolve_string(context, _get(inputs, "text"), seen)
text = _trim(raw)
if not text:
return ""
# One shared set even across siblings keeps crafted chains linear.
prepend = _trim(
(yield _resolve_string(context, _get(inputs, "prepend_text"), seen))
)
append = _trim((yield _resolve_string(context, _get(inputs, "append_text"), seen)))
# Older saves stored the already-combined prompt in ``text``.
parts = (
prepend if prepend and not text.startswith(prepend) else "",
text,
append if append and not text.endswith(append) else "",
)
return " ".join(part for part in parts if part)
def _trace_conditioning(context, link, role, seen):
"""Follow a conditioning link upstream to the encoder that produced it."""
entered = _enter(context, link, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(link[1], 1):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(link[1], 0):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
# Pass-through nodes (ControlNet apply, conditioning combine/set area, ...).
following = _first_truthy(
_get(inputs, role),
_get(inputs, "conditioning"),
_get(inputs, "conditioning_to"),
_get(inputs, "conditioning_1"),
)
return (yield _trace_conditioning(context, following, role, seen))
def _trace_model_name(context, link, seen):
"""Follow the model link upstream (through LoRA loaders etc.) to a loader."""
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None)
if key is not None:
return _get(inputs, key)
return (yield _trace_model_name(context, _get(inputs, "model"), seen))
def _find_any_model_name(nodes):
for node in nodes:
inputs = _and_chain(node, "inputs")
if not _truthy(inputs):
inputs = {}
key = next(
(k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None
)
if key is not None:
return _get(inputs, key)
return UNDEFINED
def _from_prompt_graph(graph, workflow):
context = _create_context(graph, workflow)
nodes = _values(graph)
custom = next((node for node in nodes if _is_custom_sampler(node)), UNDEFINED)
guider = UNDEFINED
if custom is not UNDEFINED:
entered = _enter(context, _get(_get(custom, "inputs"), "guider"), set())
guider = entered[1] if entered is not None else UNDEFINED
sampler = (
guider
if _is_sampler(guider)
else next((n for n in nodes if _is_sampler(n)), UNDEFINED)
)
if sampler is UNDEFINED:
loose = next(
(
n
for n in nodes
if _truthy(n)
and _truthy(_get(n, "inputs"))
and _matches(_KSAMPLER, _get(n, "class_type"))
),
UNDEFINED,
)
inputs = _and_chain(loose, "inputs")
if not _truthy(inputs):
inputs = {}
seed = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
return {
"checkpoint": _find_any_model_name(nodes),
"seed": (yield _resolve_scalar(context, seed, "seed", set())),
"steps": (
yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
),
"cfgScale": (
yield _resolve_scalar(context, _get(inputs, "cfg"), "cfg", set())
),
"sampler": (
yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
),
}
inputs = _get(sampler, "inputs")
def from_custom(field, keys):
if custom is UNDEFINED:
return UNDEFINED
return _run(
_resolve_linked_input(context, _get(_get(custom, "inputs"), field), keys)
)
result = {
"positivePrompt": (
yield _trace_conditioning(
context, _get(inputs, "positive"), "positive", set()
)
),
"negativePrompt": (
yield _trace_conditioning(
context, _get(inputs, "negative"), "negative", set()
)
),
}
model = yield _trace_model_name(context, _get(inputs, "model"), set())
result["checkpoint"] = model if _truthy(model) else _find_any_model_name(nodes)
seed_value = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
seed_name = "seed" if _has(inputs, "seed") else "noise_seed"
seed = yield _resolve_scalar(context, seed_value, seed_name, set())
result["seed"] = _coalesce(
seed, lambda: from_custom("noise", ("noise_seed", "seed"))
)
steps = yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
result["steps"] = _coalesce(steps, lambda: from_custom("sigmas", ("steps",)))
result["cfgScale"] = yield _resolve_scalar(
context, _get(inputs, "cfg"), "cfg", set()
)
sampler_name = yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
result["sampler"] = _coalesce(
sampler_name, lambda: from_custom("sampler", ("sampler_name",))
)
return result
# --- UI workflow graph -------------------------------------------------------------
def _widget_names(node):
inputs = _first_truthy(_get(node, "inputs"), [])
from_inputs = [
_get(_get(item, "widget"), "name")
for item in _array(inputs)
if _truthy(item)
and _truthy(_get(item, "widget"))
and _truthy(_get(_get(item, "widget"), "name"))
]
if from_inputs:
return from_inputs
node_type = _get(node, "type")
key = _js_string(node_type)
if key in _OBJECT_PROTOTYPE_KEYS:
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
if key in WIDGET_POSITIONS:
return list(WIDGET_POSITIONS[key])
if _matches(_CHECKPOINT_LOADER, node_type):
return ["ckpt_name"]
if _matches(_UNET, node_type):
return ["unet_name"]
return []
def _named_widgets(node):
"""Map widget values to input names, skipping control_after_generate."""
named = _get(node, "widgets_values_named")
if _truthy(named) and isinstance(named, dict):
return dict(named)
values = _widgets_of(node)
out = {}
position = 0
for name in _iterate(_widget_names(node)):
if position >= len(values):
break
_set(out, name, values[position])
position += 1
following = values[position] if position < len(values) else UNDEFINED
if (
_SEED_NAME.search(_js_string(name))
and isinstance(following, str)
and following in SEED_CONTROL_VALUES
):
position += 1
return out
def _workflow_to_graph(workflow):
"""Convert a UI workflow ({nodes, links}) into the API graph shape.
Set/Get pairs, Reroutes and bypassed nodes resolve to their real source;
muted nodes are dropped.
"""
nodes = [
n
for n in _workflow_nodes(workflow)
if _truthy(n) and not _nullish(_get(n, "id"))
]
by_id = {}
for node in nodes:
by_id[_js_string(_get(node, "id"))] = node
links = {}
raw_links = _get(workflow, "links")
for link in raw_links if isinstance(raw_links, list) else []:
if isinstance(link, list):
links[_same_value_key(_get(link, 0))] = (
_get(link, 1),
_get(link, 2),
_get(link, 5),
)
elif _truthy(link) and _typeof(link) == "object":
links[_same_value_key(_get(link, "id"))] = (
_get(link, "origin_id"),
_get(link, "origin_slot"),
_get(link, "type"),
)
setters = {}
for node in nodes:
if _strict_equal(_get(node, "type"), "SetNode"):
setters[_same_value_key(_get(_widgets_of(node), 0))] = _and_chain(
node, "inputs", 0, "link"
)
def source(link_id):
hops = 0
while True:
link = links.get(_same_value_key(link_id))
node = by_id.get(_js_string(link[0])) if link is not None else None
if (
node is None
or _strict_equal(_get(node, "mode"), MODE_MUTED)
or hops > MAX_VISITS
):
return UNDEFINED
hops += 1
node_type = _get(node, "type")
if _strict_equal(node_type, "Reroute"):
link_id = _and_chain(node, "inputs", 0, "link")
continue
if _strict_equal(node_type, "GetNode"):
link_id = setters.get(
_same_value_key(_get(_widgets_of(node), 0)), UNDEFINED
)
continue
if _strict_equal(_get(node, "mode"), MODE_BYPASSED):
link_type = link[2]
same = next(
(
item
for item in _array(_first_truthy(_get(node, "inputs"), []))
if _truthy(item)
and not _nullish(_get(item, "link"))
and (
not _truthy(link_type)
or _strict_equal(link_type, "*")
or _strict_equal(_get(item, "type"), link_type)
)
),
UNDEFINED,
)
if same is UNDEFINED:
return UNDEFINED
link_id = _get(same, "link")
continue
return [_js_string(_get(node, "id")), link[1]]
graph = {}
for node in nodes:
mode = _get(node, "mode")
node_type = _get(node, "type")
if (
_strict_equal(mode, MODE_MUTED)
or _strict_equal(mode, MODE_BYPASSED)
or (isinstance(node_type, str) and node_type in PASS_THROUGH_TYPES)
):
continue
inputs = _named_widgets(node)
for item in _iterate(_first_truthy(_get(node, "inputs"), [])):
if not _truthy(item) or _nullish(_get(item, "link")):
continue
found = source(_get(item, "link"))
if _truthy(found):
_set(inputs, _get(item, "name"), found)
_set(graph, _get(node, "id"), {"class_type": node_type, "inputs": inputs})
return graph
# --- Public API ----------------------------------------------------------------------
def _has_value(value):
return not _nullish(value) and not (isinstance(value, str) and value == "")
def extract_generation_params(data):
"""Prompts, model and sampler settings from parsed ``prompt``/``workflow`` records.
``data`` holds the parsed records (``{"prompt": ..., "workflow": ...}``). Each
field falls back to its placeholder when neither graph provides it.
Raises ``GenerationParamsError`` where the browser parser throws.
"""
data = data if _truthy(data) else {}
prompt = _get(data, "prompt")
workflow = _get(data, "workflow")
primary = {}
if _truthy(prompt) and _typeof(prompt) == "object":
primary = _run(_from_prompt_graph(prompt, workflow))
fallback = {}
if _truthy(workflow):
fallback = _run(_from_prompt_graph(_workflow_to_graph(workflow), None))
merged = {}
for key, placeholder in PLACEHOLDERS.items():
value = primary.get(key, UNDEFINED)
if not _has_value(value):
value = fallback.get(key, UNDEFINED)
merged[key] = value if _has_value(value) else placeholder
return merged
def _reject_constant(name):
raise ValueError(f"{name} is not JSON")
def _double_integer(text):
"""Integers parse as doubles in JavaScript; beyond 2**53 they round."""
value = int(text)
return value if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER else float(text)
def parse_js_json(text):
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``)."""
if not isinstance(text, str):
return None
try:
return json.loads(
text, parse_constant=_reject_constant, parse_int=_double_integer
)
except (ValueError, RecursionError):
return None
def extract_from_records(prompt_text, workflow_text):
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
A record that is absent or fails to parse is treated as absent, as the
browser pages do.
"""
data = {}
for key, text in (("prompt", prompt_text), ("workflow", workflow_text)):
if isinstance(text, str):
data[key] = parse_js_json(text)
return extract_generation_params(data)
+111
View File
@@ -0,0 +1,111 @@
"""User-facing lines for non-resolved prompt outcomes, from the closed vocabulary.
The inspector shows one line per unresolved role in place of the generic
"Prompt could not be resolved." Ambiguous and absent roles keep their status
copy, so a candidate is never presented as a confirmed prompt. The wire
projection accepts exactly the closed ``reason_code`` vocabulary and names a
blocking node only beside a code that names one.
"""
from typing import Any, Optional
from .prompt_pair_resolver import (
EMPTY_POSITIVE_REASON,
MULTISTAGE_UNATTRIBUTED_REASON,
SEMANTICS_UNAVAILABLE_REASON,
)
REASON_CODES = frozenset(
{
"work-limit",
"depth-limit",
"text-limit",
"graph-ceiling",
"unsupported-node",
"runtime-value",
"composite",
"no-sampler",
"malformed",
"multistage-differs",
}
)
LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit", "graph-ceiling"})
# Codes whose outcome names the blocking node: ``node: {id, class_type}``.
NAMED_CODES = frozenset({"unsupported-node", "runtime-value", "composite"})
# Bound for a node ID or class name on the wire and in a line.
CLASS_NAME_LENGTH = 200
# Bound for a role's line on the wire, in UTF-16 code units (the client's count).
MESSAGE_LENGTH = 512
UNREADABLE_METADATA_REASON = "Media metadata could not be read"
_NODE_KEYS = frozenset({"id", "class_type"})
_NAMED_LINES = {
"unsupported-node": "Prompt uses a node the inspector does not support",
"runtime-value": "Prompt is chosen at run time",
"composite": "Prompt combines several texts",
}
_CODED_LINES = {
**dict.fromkeys(LIMIT_CODES, "Prompt exceeds inspection limits."),
"no-sampler": "No supported sampler was found.",
"malformed": "Prompt metadata in this file is malformed.",
"multistage-differs": "Sampler stages have different prompts.",
}
# Uncoded outcomes, keyed by the resolver's exact reason.
_UNCODED_LINES = {
EMPTY_POSITIVE_REASON: "Positive prompt is empty.",
MULTISTAGE_UNATTRIBUTED_REASON: (
"Several sampler stages were found; their prompts were not compared."
),
SEMANTICS_UNAVAILABLE_REASON: "The inspector's node table is unavailable.",
UNREADABLE_METADATA_REASON: "Metadata in this file could not be read.",
}
def _text(value: Any) -> Optional[str]:
return value if isinstance(value, str) else None
def _class_name(node: Any) -> Optional[str]:
if not isinstance(node, dict):
return None
name = node.get("class_type")
return name[:CLASS_NAME_LENGTH] if isinstance(name, str) and name else None
def outcome_message(resolution: Any) -> Optional[str]:
"""One line for an unresolved outcome, or ``None`` to keep the status copy."""
if not isinstance(resolution, dict) or resolution.get("state") != "unresolved":
return None
code = _text(resolution.get("reason_code"))
if code in _NAMED_LINES:
name = _class_name(resolution.get("node"))
return f"{_NAMED_LINES[code]} ({name})." if name else f"{_NAMED_LINES[code]}."
if code is None:
return _UNCODED_LINES.get(_text(resolution.get("reason")))
return _CODED_LINES.get(code)
def _node(code: Optional[str], node: Any) -> Optional[dict]:
if code not in NAMED_CODES or not isinstance(node, dict) or set(node) != _NODE_KEYS:
return None
node_id, name = node["id"], node["class_type"]
if not isinstance(node_id, str) or not node_id:
return None
if name is not None and not isinstance(name, str):
return None
return {
"id": node_id[:CLASS_NAME_LENGTH],
"class_type": name[:CLASS_NAME_LENGTH] if name else None,
}
def project_resolution(resolution: dict) -> dict:
"""Fail closed: an unknown or contradictory code and its node are dropped."""
code = _text(resolution.get("reason_code"))
if code not in REASON_CODES or resolution.get("state") in ("resolved", "absent"):
code = None
return {
**resolution,
"reason_code": code,
"node": _node(code, resolution.get("node")),
}
+50 -11
View File
@@ -3,7 +3,9 @@
import json
import math
from decimal import Decimal, InvalidOperation
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Any, Literal, Optional
from .inspection_outcomes import MESSAGE_LENGTH, project_resolution
if TYPE_CHECKING:
from .prompt_inspector import InspectionRead
@@ -159,6 +161,21 @@ def _graph(value: object, *, workflow: bool = False) -> dict:
return {"status": "parsed", "value": value}
def project_graph(text: str, kind: Literal["prompt", "workflow"]) -> dict:
"""One record's graph as the inspector projects it: ``{status, value}``.
``status`` is ``parsed`` (``value`` is the projected graph the browser
parser receives), ``limited`` (past the depth or node bound) or
``malformed``. Shared with the workspace card so both read one bound.
"""
try:
return _graph(_strict_projection(text, kind=kind), workflow=kind == "workflow")
except _ProjectionLimit:
return {"status": "limited", "value": None}
except (ValueError, TypeError, KeyError, OverflowError):
return {"status": "malformed", "value": None}
def _metadata(inspection: "InspectionRead | None") -> dict:
formats = (
{}
@@ -211,16 +228,15 @@ def _metadata(inspection: "InspectionRead | None") -> dict:
graph["status"] = failed or "limited"
elif formats.get(key) == "invalid":
graph["status"] = "malformed"
elif carrier is None and formats.get(key) == "reserialized-json":
graph["status"] = "limited"
elif carrier is None:
graph = project_graph(raw.get(key), key)
else:
try:
if carrier is not None:
value = carrier.get(key)
if isinstance(value, str):
value = _strict_projection(value, kind=key)
elif formats.get(key) == "reserialized-json":
raise _ProjectionLimit()
else:
value = _strict_projection(raw[key], kind=key)
value = carrier.get(key)
if isinstance(value, str):
value = _strict_projection(value, kind=key)
graph = _graph(value, workflow=key == "workflow")
except _ProjectionLimit:
graph["status"] = "limited"
@@ -312,6 +328,29 @@ def _library_projection(library: dict) -> dict:
return result
def _bounded_message(value: Any) -> Optional[str]:
"""A role line as the client accepts it: 1 to 512 UTF-16 code units."""
if not isinstance(value, str) or not value:
return None
value = value[:MESSAGE_LENGTH]
while len(value.encode("utf-16-le", "surrogatepass")) > 2 * MESSAGE_LENGTH:
value = value[:-1]
return value
def _bounded_messages(prompts: dict) -> dict:
"""Keep a role ``message`` only as bounded text on a non-resolved role."""
bounded = {}
for role, side in prompts.items():
if isinstance(side, dict) and "message" in side:
side = {key: item for key, item in side.items() if key != "message"}
message = _bounded_message(prompts[role]["message"])
if message is not None and side.get("status") != "resolved":
side["message"] = message
bounded[role] = side
return bounded
def _encode_inspection(
*,
identity: dict,
@@ -328,8 +367,8 @@ def _encode_inspection(
"inspection_version": 1,
**identity,
"file": _file_projection(file, inspection),
"prompts": prompts,
"resolution": resolution,
"prompts": _bounded_messages(prompts),
"resolution": project_resolution(resolution),
"metadata": metadata,
"library": _library_projection(library),
}
+492
View File
@@ -0,0 +1,492 @@
"""Load and validate the declarative inspection node semantics table (#240).
The table lives once in ``web/js/inspection-semantics.json`` so a browser-local
resolver can load the same data. It maps node classes (or ``(class, output
slot)`` pairs) to a named strategy from the closed ``STRATEGIES`` set plus the
exact input names that strategy reads. Code implements each strategy; adding a
node that reuses one is a data-only change.
Only the inspection resolver profile consumes this table, through
``get_semantics()``, which reads and validates it on first use and caches only a
valid result. Importing this module reads nothing, and the default (ingestion)
profile never imports it, so a missing or bad table can never break ingestion.
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
silently changing what the inspector resolves. The file is bounded in size and
nesting before it is validated. Every class appears in exactly one category;
classes that are not listed stay ``unsupported-node``.
"""
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from types import MappingProxyType
from typing import Any, Callable, Dict, FrozenSet, Mapping, Optional, Tuple, Union
SEMANTICS_PATH = (
Path(__file__).resolve().parents[1] / "web" / "js" / "inspection-semantics.json"
)
SCHEMA_VERSION = 1
# The real table is a few KiB and four levels deep; these only reject nonsense.
MAX_TABLE_BYTES = 1024 * 1024
MAX_NESTING = 8
_NAME_LENGTH = 128
# Comfy output slots are small; this only rejects nonsense, never a real node.
_MAX_SLOT = 255
STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
{
"samplers": frozenset({"ksampler", "custom-advanced", "detailer-stage"}),
"guiders": frozenset({"cfg", "basic"}),
"conditioning": frozenset({"passthrough", "zeroed"}),
"selectors": frozenset({"boolean-select"}),
"composites": frozenset({"combine"}),
"encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
"strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
"savers": frozenset({"comfy-counter", "vhs-counter"}),
}
)
# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
FACT_SHAPES = frozenset({"ksampler", "custom", "custom-advanced"})
SAVER_PATTERNS = MappingProxyType(
{
"comfy-counter": "{name}_{counter:05}_.{ext}",
"vhs-counter": "{name}_{counter:05}.{ext}",
}
)
_SAMPLER_OPTIONS = frozenset({"prompt_overrides", "fact_overrides"})
_CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"})
_SCALAR_KINDS = frozenset({"number", "string"})
_TYPE_FIELDS = frozenset({"type_fields"})
_CLASS_CATEGORIES = ("samplers", "guiders", "selectors", "composites", "savers")
_SLOT_CATEGORIES = ("conditioning", "encoders", "strings")
_SET_CATEGORIES = ("runtime", "non_file_outputs")
_FACTS_TABLES = (
"configuration_outputs",
"model_outputs",
"model_passthroughs",
"scalar_outputs",
)
_CATEGORIES = frozenset(
("schema",)
+ _CLASS_CATEGORIES
+ _SLOT_CATEGORIES
+ _SET_CATEGORIES
+ ("stage_inputs", "facts")
)
# Input names that make an unlisted node a possible sampler stage in an ancestry
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
# without a ``positive`` link), and those that do so beside a linked ``model``
# (``with_model``: XlabsSampler's ``conditioning``, video samplers' ``samples``).
# ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a model patch.
# The resolver and comfy-metadata.js both read these lists.
_STAGE_INPUT_LISTS = ("shaped", "with_model")
_STRING_PARAMS = MappingProxyType(
{
"literal": frozenset(),
"concat": frozenset(),
"strip-newlines": frozenset({"flag"}),
"join-multi": frozenset({"count", "delimiter", "list_flag", "input_prefix"}),
}
)
Frozen = Union[Mapping[str, Any], Tuple[Any, ...], str, int, None]
class SemanticsError(ValueError):
"""The semantics table is unreadable or violates its schema."""
@dataclass(frozen=True)
class Semantics:
"""Validated, deeply frozen semantics. Slot categories key by ``(class, slot)``."""
samplers: Mapping[str, Mapping[str, Any]]
guiders: Mapping[str, Mapping[str, Any]]
conditioning: Mapping[Tuple[str, int], Mapping[str, Any]]
selectors: Mapping[str, Mapping[str, Any]]
composites: Mapping[str, Mapping[str, Any]]
encoders: Mapping[Tuple[str, int], Mapping[str, Any]]
strings: Mapping[Tuple[str, int], Mapping[str, Any]]
runtime: FrozenSet[str]
savers: Mapping[str, Mapping[str, Any]]
non_file_outputs: FrozenSet[str]
stage_inputs: Mapping[str, Tuple[str, ...]]
facts: Mapping[str, Any]
all_classes: FrozenSet[str]
def _freeze(value: Any) -> Frozen:
if isinstance(value, dict):
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
if isinstance(value, list):
return tuple(_freeze(item) for item in value)
return value
def _reject_duplicate_keys(pairs: list) -> dict:
result: Dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise SemanticsError(f"duplicate key {key!r}")
result[key] = value
return result
def _reject_constant(name: str) -> None:
raise SemanticsError(f"non-finite number {name} is not allowed")
def _name(value: Any, where: str, what: str = "input") -> str:
if not isinstance(value, str) or not value or len(value) > _NAME_LENGTH:
raise SemanticsError(f"{where}: missing or invalid {what} name {value!r}")
return value
def _names(value: Any, where: str, minimum: int = 1) -> None:
if not isinstance(value, list) or len(value) < minimum:
raise SemanticsError(f"{where}: needs at least {minimum} input name(s)")
for item in value:
_name(item, where)
if len(set(value)) != len(value):
raise SemanticsError(f"{where}: repeated input name")
def _fields(
entry: Any, where: str, required: FrozenSet[str], optional: FrozenSet[str]
) -> None:
if not isinstance(entry, dict):
raise SemanticsError(f"{where}: entry must be an object")
missing = sorted(required - entry.keys())
if missing:
raise SemanticsError(f"{where}: missing input field(s) {missing}")
unknown = sorted(entry.keys() - required - optional)
if unknown:
raise SemanticsError(f"{where}: unknown field(s) {unknown}")
def _strategy(entry: Any, category: str, where: str) -> str:
strategy = entry.get("strategy") if isinstance(entry, dict) else None
if not isinstance(strategy, str) or strategy not in STRATEGIES[category]:
raise SemanticsError(f"{where}: unknown strategy {strategy!r}")
return strategy
def _check_sampler(entry: dict, strategy: str, where: str) -> None:
stage = "image_stage" if strategy == "detailer-stage" else "latent"
_fields(
entry,
where,
frozenset({"strategy", "conditioning", stage, "facts"}),
_SAMPLER_OPTIONS,
)
_name(entry[stage], where)
# Inputs that, when set, replace the prompt the sampler's conditioning
# inputs carry (a detailer's wildcard); the prompt is then not read.
# ``fact_overrides`` (read by comfy-metadata.js): inputs that, when set,
# replace the settings the facts would read (a script or an XY plot).
for option in sorted(_SAMPLER_OPTIONS & entry.keys()):
_names(entry[option], f"{where}.{option}")
keys = {"guider"} if strategy == "custom-advanced" else {"positive", "negative"}
conditioning = entry["conditioning"]
_fields(conditioning, where + ".conditioning", frozenset(keys), frozenset())
for key in keys:
_name(conditioning[key], where + ".conditioning")
if entry["facts"] not in FACT_SHAPES:
raise SemanticsError(f"{where}: unknown facts shape {entry['facts']!r}")
def _check_guider(entry: dict, strategy: str, where: str) -> None:
if strategy == "cfg":
_fields(
entry, where, frozenset({"strategy", "positive", "negative"}), frozenset()
)
_name(entry["negative"], where)
else:
# Negative absent by design; its reason is fixed text, not an input.
_fields(
entry,
where,
frozenset({"strategy", "positive"}),
frozenset({"absent_reason"}),
)
if "absent_reason" in entry:
_name(entry["absent_reason"], where, "absent reason")
_name(entry["positive"], where)
def _check_selector(entry: dict, strategy: str, where: str) -> None:
_fields(entry, where, frozenset({"strategy", "condition", "branches"}), frozenset())
_name(entry["condition"], where)
branches = entry["branches"]
_fields(branches, where + ".branches", frozenset({"true", "false"}), frozenset())
_name(branches["true"], where)
_name(branches["false"], where)
def _check_composite(entry: dict, strategy: str, where: str) -> None:
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
_names(entry["inputs"], where, minimum=2)
def _check_saver(entry: dict, strategy: str, where: str) -> None:
_fields(
entry,
where,
frozenset({"strategy", "prefix_input", "image_input", "pattern"}),
frozenset(),
)
_name(entry["prefix_input"], where)
_name(entry["image_input"], where)
if entry["pattern"] != SAVER_PATTERNS[strategy]:
raise SemanticsError(f"{where}: pattern does not match {strategy}")
def _check_conditioning(entry: dict, strategy: str, where: str) -> None:
_fields(entry, where, frozenset({"strategy", "input"}), frozenset())
_name(entry["input"], where)
def _check_encoder(entry: dict, strategy: str, where: str) -> None:
if strategy == "flag-parts":
# A literal boolean ``flag`` false reads the first input; true needs
# every input equal (smZ CLIPTextEncode's with_SDXL).
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
params = entry["params"]
if not isinstance(params, dict) or set(params) != {"flag"}:
raise SemanticsError(f"{where}: params must be exactly ['flag']")
_name(params["flag"], where + ".params")
_names(entry["inputs"], where, minimum=2)
return
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"compose"}))
_names(entry["inputs"], where, minimum=2 if strategy == "parts-equal" else 1)
if "compose" in entry and not isinstance(entry["compose"], bool):
raise SemanticsError(f"{where}: compose must be a boolean")
def _check_string(entry: dict, strategy: str, where: str) -> None:
required = _STRING_PARAMS[strategy]
if required:
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
params = entry["params"]
if not isinstance(params, dict) or set(params) != required:
raise SemanticsError(f"{where}: params must be exactly {sorted(required)}")
for value in params.values():
_name(value, where + ".params")
else:
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
_names(entry["inputs"], where)
_CHECKS: Mapping[str, Callable[[dict, str, str], None]] = MappingProxyType(
{
"samplers": _check_sampler,
"guiders": _check_guider,
"selectors": _check_selector,
"composites": _check_composite,
"savers": _check_saver,
"conditioning": _check_conditioning,
"encoders": _check_encoder,
"strings": _check_string,
}
)
class _Owners:
"""Each class belongs to one category; each (class, slot) is listed once."""
def __init__(self) -> None:
self._category: Dict[str, str] = {}
self._outputs: set = set()
def claim(self, name: str, category: str, slot: Optional[int] = None) -> None:
owner = self._category.setdefault(name, category)
if owner != category:
raise SemanticsError(f"duplicate class {name!r} in {owner} and {category}")
key = (name, slot)
if slot is not None and key in self._outputs:
raise SemanticsError(f"{category}: duplicate output key {key!r}")
self._outputs.add(key)
def classes(self) -> FrozenSet[str]:
return frozenset(self._category)
def _class_category(doc: dict, category: str, owners: _Owners) -> Mapping:
table = doc[category]
if not isinstance(table, dict):
raise SemanticsError(f"category {category} must be an object")
result = {}
for name, entry in table.items():
where = f"{category}[{name!r}]"
_name(name, where, "class")
strategy = _strategy(entry, category, where)
_CHECKS[category](entry, strategy, where)
owners.claim(name, category)
result[name] = _freeze(entry)
return MappingProxyType(result)
def _slot(entry: dict, where: str) -> int:
slot = entry.get("slot")
# bool is an int subclass; a JSON true is not an output slot.
if type(slot) is not int or not 0 <= slot <= _MAX_SLOT:
raise SemanticsError(f"{where}: slot must be a non-negative int, not {slot!r}")
return slot
def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
rows = doc[category]
if not isinstance(rows, list):
raise SemanticsError(f"category {category} must be a list")
result = {}
for index, row in enumerate(rows):
where = f"{category}[{index}]"
if not isinstance(row, dict):
raise SemanticsError(f"{where}: entry must be an object")
name = _name(row.get("class"), where, "class")
slot = _slot(row, where)
entry = {
key: value for key, value in row.items() if key not in ("class", "slot")
}
strategy = _strategy(entry, category, where)
_CHECKS[category](entry, strategy, where)
owners.claim(name, category, slot)
result[(name, slot)] = _freeze(entry)
return MappingProxyType(result)
def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
rows = doc[category]
if not isinstance(rows, list):
raise SemanticsError(f"category {category} must be a list")
seen: set = set()
for name in rows:
_name(name, category, "class")
if name in seen:
raise SemanticsError(f"{category}: duplicate class {name!r}")
seen.add(name)
owners.claim(name, category)
return frozenset(seen)
def _string_map(table: Any, where: str, allowed: Optional[FrozenSet[str]]) -> None:
if not isinstance(table, dict):
raise SemanticsError(f"{where} must be an object")
for name, value in table.items():
_name(name, where, "class")
_name(value, where)
if allowed is not None and value not in allowed:
raise SemanticsError(f"{where}[{name!r}]: unknown kind {value!r}")
def _stage_inputs(doc: dict) -> Mapping[str, Tuple[str, ...]]:
table = doc["stage_inputs"]
if not isinstance(table, dict) or set(table) != set(_STAGE_INPUT_LISTS):
raise SemanticsError(
f"stage_inputs must have exactly {list(_STAGE_INPUT_LISTS)}"
)
for key in _STAGE_INPUT_LISTS:
_names(table[key], f"stage_inputs.{key}")
if "model" in table[key]:
raise SemanticsError(f"stage_inputs.{key}: model is a patch input")
if set(table["shaped"]) & set(table["with_model"]):
raise SemanticsError("stage_inputs: an input is listed in both lists")
return _freeze(table)
def _facts(doc: dict) -> Mapping[str, Any]:
facts = doc["facts"]
if not isinstance(facts, dict) or set(facts) != set(_FACTS_TABLES):
raise SemanticsError(f"facts must have exactly {list(_FACTS_TABLES)}")
configuration = facts["configuration_outputs"]
if not isinstance(configuration, dict):
raise SemanticsError("facts.configuration_outputs must be an object")
for name, adapter in configuration.items():
where = f"facts.configuration_outputs[{name!r}]"
_name(name, where, "class")
_fields(adapter, where, frozenset({"kind", "fields"}), _TYPE_FIELDS)
if adapter["kind"] not in _CONFIGURATION_KINDS:
raise SemanticsError(f"{where}: unknown kind {adapter['kind']!r}")
_names(adapter["fields"], where)
if "type_fields" in adapter:
# A fact whose value is the provider's node type (D8: Flux2Scheduler).
_names(adapter["type_fields"], where)
if set(adapter["type_fields"]) & set(adapter["fields"]):
raise SemanticsError(f"{where}: type_fields repeat an input field")
_string_map(facts["model_outputs"], "facts.model_outputs", None)
_string_map(facts["scalar_outputs"], "facts.scalar_outputs", _SCALAR_KINDS)
passthroughs = facts["model_passthroughs"]
_names(passthroughs, "facts.model_passthroughs", minimum=0)
return _freeze(facts)
def _check_nesting(doc: Any) -> None:
"""Reject containers nested deeper than ``MAX_NESTING`` (iterative walk)."""
stack = [(doc, 1)]
while stack:
value, level = stack.pop()
if not isinstance(value, (dict, list)):
continue
if level > MAX_NESTING:
raise SemanticsError(f"semantics table nests deeper than {MAX_NESTING}")
items = value.values() if isinstance(value, dict) else value
stack.extend((item, level + 1) for item in items)
def _validate(doc: Any) -> Semantics:
if not isinstance(doc, dict):
raise SemanticsError("semantics table must be an object")
if set(doc) != _CATEGORIES:
missing = sorted(_CATEGORIES - doc.keys())
unknown = sorted(doc.keys() - _CATEGORIES)
raise SemanticsError(f"category mismatch: missing {missing}, unknown {unknown}")
if type(doc["schema"]) is not int or doc["schema"] != SCHEMA_VERSION:
raise SemanticsError(f"unsupported schema {doc['schema']!r}")
owners = _Owners()
tables: Dict[str, Any] = {}
for category in _CLASS_CATEGORIES:
tables[category] = _class_category(doc, category, owners)
for category in _SLOT_CATEGORIES:
tables[category] = _slot_category(doc, category, owners)
for category in _SET_CATEGORIES:
tables[category] = _set_category(doc, category, owners)
return Semantics(
stage_inputs=_stage_inputs(doc),
facts=_facts(doc),
all_classes=owners.classes(),
**tables,
)
def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
"""Read and validate a semantics table; ``SemanticsError`` on any problem."""
source = Path(path) if path is not None else SEMANTICS_PATH
try:
with source.open("rb") as stream:
data = stream.read(MAX_TABLE_BYTES + 1)
if len(data) > MAX_TABLE_BYTES:
raise SemanticsError(
f"semantics table {source.name} exceeds {MAX_TABLE_BYTES} bytes"
)
doc = json.loads(
data.decode("utf-8"),
object_pairs_hook=_reject_duplicate_keys,
parse_constant=_reject_constant,
)
except SemanticsError:
raise
except RecursionError:
raise SemanticsError(
f"semantics table {source.name} nests deeper than {MAX_NESTING}"
) from None
except (OSError, UnicodeDecodeError, ValueError) as error:
raise SemanticsError(f"cannot read semantics table {source.name}: {error}")
_check_nesting(doc)
return _validate(doc)
@lru_cache(maxsize=1)
def get_semantics() -> Semantics:
"""The repository table, loaded on first use; failures are not cached."""
return load_semantics()
+139 -21
View File
@@ -3,6 +3,7 @@
import hashlib
import io
import json
import re
import struct
import subprocess # nosec B404
import threading
@@ -13,8 +14,11 @@ from typing import Any
from PIL import Image
from .inspection_outcomes import UNREADABLE_METADATA_REASON, outcome_message
from .pair_execution import EXECUTION_KEY
from .prompt_pair_resolver import (
_FOOTER,
EMPTY_POSITIVE_REASON,
INSPECTION_PROFILE,
ResolverProfile,
resolve_prompt_pair,
@@ -39,6 +43,23 @@ MAX_PNG_CHUNKS = 10_000
_ROLE_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY})
_METADATA_KEYS = _ROLE_METADATA_KEYS | {"workflow"}
_TEXT_CHUNKS = (b"tEXt", b"zTXt", b"iTXt")
# An accumulating batch saver writes one A1111 ``parameters`` record per batch
# item, identical except this field (R3); Pillow keeps the last one.
# Matched only inside the record's one ``Steps: N, ...`` settings footer line.
_BATCH_INDEX = re.compile(r"(?:^|(?<=, ))Batch index: \d+(?=, |$)")
def _batch_normalized(text: str) -> str | None:
"""``text`` with its footer's single batch index blanked, else ``None``."""
footers = list(_FOOTER.finditer(text))
if len(footers) != 1:
return None
line, count = _BATCH_INDEX.subn("Batch index: #", footers[0].group())
if count != 1:
return None
return text[: footers[0].start()] + line + text[footers[0].end() :]
_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@@ -121,13 +142,14 @@ class _PngRecords:
self.digests: dict[str, tuple[int, bytes]] = {}
self.role_encoded = self.role_decoded = 0
self.duplicate_workflow = self.duplicate_role = 0
self.duplicate_decoded = 0
def skip(self, key: str, length: int) -> bool:
"""Decide from the keyword and length alone whether to seek past a chunk."""
if key not in _METADATA_KEYS or key in self.limited:
return True # never retained, or a duplicate of a limited workflow
if key in self.digests:
if self.digests[key][0] != length:
if self.digests[key][0] != length and key != "parameters":
raise MetadataReadError("Duplicate prompt metadata")
if key == "workflow":
if self.duplicate_workflow + length > DUPLICATE_WORKFLOW_BYTES:
@@ -155,7 +177,11 @@ class _PngRecords:
digest = hashlib.sha256(kind + data).digest()
if key in self.digests:
if self.digests[key][1] != digest:
raise MetadataReadError("Duplicate prompt metadata")
if key != "parameters":
raise MetadataReadError("Duplicate prompt metadata")
self.batch_copy(kind, data)
# The kept copy is the last one; later repeats compare with it.
self.digests[key] = (len(data), digest)
return
self.digests[key] = (len(data), digest)
workflow = key == "workflow"
@@ -176,6 +202,27 @@ class _PngRecords:
raise MetadataReadError("Metadata exceeds inspection limits")
self.found[key] = value
def batch_copy(self, kind: bytes, data: bytes) -> None:
"""Keep a later ``parameters`` copy that differs only in its batch index.
Each copy must carry exactly one ``Batch index: N`` settings field; any
other difference is still a conflict. Decoding every copy draws from the
duplicate role budget, so small compressed copies stay bounded.
"""
_, value = _png_text(kind, data, ROLE_METADATA_BYTES)
size = ROLE_METADATA_BYTES + 1 if value is None else len(value.encode())
self.duplicate_decoded += size
if value is None or self.duplicate_decoded > DUPLICATE_ROLE_BYTES:
raise MetadataReadError("Metadata exceeds inspection limits")
kept = self.found["parameters"]
normalized = _batch_normalized(kept)
if normalized is None or normalized != _batch_normalized(value):
raise MetadataReadError("Duplicate prompt metadata")
self.role_decoded += max(0, size - len(kept.encode()))
if self.role_decoded > ROLE_METADATA_BYTES:
raise MetadataReadError("Metadata exceeds inspection limits")
self.found["parameters"] = value
def _png_metadata(stream: Any, size: int) -> tuple[dict, frozenset, int, int]:
"""CRC-check retained text and structural chunks, seeking past everything else.
@@ -400,6 +447,70 @@ def _side(status: str, text: str | None = None, source: str | None = None) -> di
return {"status": status, "text": text, "source": source}
_EMPTY_POSITIVE = outcome_message(
{"state": "unresolved", "reason": EMPTY_POSITIVE_REASON}
)
def _explained(prompts: dict, resolution: dict) -> dict:
"""Each unresolved role carries the outcome's line; other roles are kept."""
message = outcome_message(resolution)
if message is None:
return prompts
return {
role: (
{**side, "message": message}
if side["status"] == "unresolved" and "message" not in side
else side
)
for role, side in prompts.items()
}
def unreadable_evidence() -> dict:
"""Both roles when the file's metadata could not be read at all."""
resolution = {
"state": "unresolved",
"sampler_id": None,
"reason": UNREADABLE_METADATA_REASON,
"reason_code": None,
"node": None,
}
prompts = {role: _side("unresolved") for role in ("positive", "negative")}
return {"prompts": _explained(prompts, resolution), "resolution": resolution}
def _may_hold_prompt_manager(metadata: dict) -> bool:
"""Whether the API graph can show the R1 signature, so the workflow is needed.
PromptManager classes, or a ``CLIPTextEncode`` carrying PromptManager's own
``search_text`` input (an older save rewrote the class).
"""
graph = metadata.get("prompt")
if not isinstance(graph, dict):
return False
for node in graph.values():
if not isinstance(node, dict):
continue
name = node.get("class_type")
inputs = node.get("inputs")
if isinstance(name, str) and "promptmanager" in name.lower():
return True
if isinstance(inputs, dict) and "search_text" in inputs:
return True
return False
def _parsed_workflow(value: Any) -> dict | None:
"""The UI workflow as an object, or ``None`` (absent, invalid or not JSON)."""
if isinstance(value, str):
try:
value = json.loads(value)
except (ValueError, RecursionError):
return None
return value if isinstance(value, dict) else None
def inspect_prompt_evidence(
metadata: dict,
saved: dict | None = None,
@@ -413,6 +524,7 @@ def inspect_prompt_evidence(
directly with its default profile. ``resolution`` carries the closed
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
"""
workflow = metadata.get("workflow")
metadata = {
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
}
@@ -447,21 +559,26 @@ def inspect_prompt_evidence(
},
}
canonical = dict(metadata)
malformed = {
"prompts": _explained(
{role: _side("unresolved") for role in ("positive", "negative")},
resolution,
),
"resolution": resolution,
}
if "prompt" not in canonical and "parameters" not in canonical:
return {
"prompts": {role: _side("unresolved") for role in ("positive", "negative")},
"resolution": resolution,
}
return malformed
if isinstance(canonical.get("prompt"), str):
try:
canonical["prompt"] = json.loads(canonical["prompt"])
except (ValueError, RecursionError):
return {
"prompts": {
role: _side("unresolved") for role in ("positive", "negative")
},
"resolution": resolution,
}
return malformed
workflow = (
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
)
if workflow is not None and "prompt" in canonical:
# Only to recognize overwritten PromptManager text (R1); never a role.
canonical["workflow"] = workflow
resolved = resolve_prompt_pair(canonical, profile=profile)
state = resolved["state"]
result = {}
@@ -475,18 +592,19 @@ def inspect_prompt_evidence(
result[role] = _side(
"resolved", side["text"], side["provenance"]["text_source"]
)
elif state == "resolved" and isinstance(side, dict):
# A resolved pair withholds only an empty positive.
result[role] = {**_side("unresolved"), "message": _EMPTY_POSITIVE}
else:
result[role] = _side(state if state != "resolved" else "unresolved")
return {
"prompts": result,
"resolution": {
"state": state,
"sampler_id": resolved.get("sampler_id"),
"reason": resolved.get("reason", ""),
"reason_code": resolved.get("reason_code"),
"node": resolved.get("node"),
},
resolution = {
"state": state,
"sampler_id": resolved.get("sampler_id"),
"reason": resolved.get("reason", ""),
"reason_code": resolved.get("reason_code"),
"node": resolved.get("node"),
}
return {"prompts": _explained(result, resolution), "resolution": resolution}
def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict:
File diff suppressed because it is too large Load Diff

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