Compare commits

...
Author SHA1 Message Date
Vito Sansevero 5f82d606b1 fix(images): name the missing link in the Set category toast
Review M4: say that no selected image is linked to a saved prompt, in
the same terms as the partial-success toast and right for one image.

Also review M1: a monitor test that two parallel samplers sharing one
positive and one negative write that pair (the resolver collapse).
2026-10-21 10:25:48 -07:00
Vito Sansevero 6da378442b fix(inspector): check only the negative the legacy parse read for a zero-out
Review I1/M2/M3/M5 of the zero-out legacy-fill guard. The guard decided
over every node with a `negative` input, so a graph whose read sampler
encodes "blurry" lost that legacy-filled negative when another branch
zeroed its own (fewer than the legacy pages: below the parity floor).

- comfy-metadata.js gains `legacyNegativeSource`, which returns the graph
  and sampler `negative` link that `extractGenerationParams` reads from.
  It reuses the parser's own sampler pick (`selectSampler`, factored out
  of `fromPromptGraph` without changing any reading). The workspace
  checks only that link: a direct zero-out shows the zeroed empty
  negative, a zero-out further up the path leaves it unfilled, and
  anything else keeps the legacy fill.
- Workflow-only files go through the parser's own workflow conversion,
  so bypassed (mode 4) and muted (mode 2) zero-outs and unconnected ones
  behave as the legacy reading does. A subgraph instance whose definition
  runs a zero-out (directly or nested) leaves the negative unfilled.
- One bounded walk with an index pointer replaces a walk per negative
  input with queue.shift().
- An ambiguous fact that the legacy parse knows (a grid's per-branch
  model) is still filled, documented: the legacy pages show it and this
  page never shows less.

Tests: the reversed mixed graph shows "blurry"; the mixed graph whose
read sampler is zeroed, the zero-out-only grid and a direct workflow
zero-out show the zeroed empty negative; a combined zero-out and a
zero-out subgraph instance stay unfilled; bypassed and unconnected
workflow zero-outs keep the legacy reading. An API-graph Reroute in
front of a zero-out gives the legacy parse no negative, so the server
answer stays (that expectation changed from zeroed).
2026-10-21 10:25:48 -07:00
Vito Sansevero 38fc3ee236 fix(images): explain why Set category has nothing to set
Set category on a selection where no image is linked to a Library prompt
said only "None of the selected images has a linked prompt." It now says
why and what it means: "The selected images aren't in your Library yet,
so there is no prompt category to set." Unit and page-workflow tests
follow the copy.
2026-10-21 09:37:42 -07:00
Vito Sansevero 6b7f9e1fee fix(inspector): never show the positive as a zeroed negative from the legacy fill
When the server leaves the negative undecided, the workspace fills it from
the legacy browser parser (comfy-metadata.js), which follows
ConditioningZeroOut back to its input and so reads the positive text as
the negative. The workspace now checks the prompt graph first:

- every sampler's negative is a ConditioningZeroOut (Reroutes allowed):
  the negative shows the server's explicitly empty zeroed state
  ({status: resolved, text: "", source: zeroed-conditioning});
- a zero-out elsewhere in a negative path (one branch zeroed, another
  encoded; a zero-out combined with other conditioning), or a
  workflow-only file holding one: the negative is not filled and keeps
  the server's status;
- graphs without a zero-out keep the legacy fill unchanged.

comfy-metadata.js is untouched; the legacy pages keep their reading.
2026-10-21 09:36:08 -07:00
Vito Sansevero dd4e3125e9 fix(resolver): identical sampler branches resolve to their shared prompt pair
A LoRA epoch-compare grid runs one sampler per epoch, every one fed by the
same positive encode and the same ConditioningZeroOut negative, and saves
one concatenated image. `_resolve` returned "Multiple sampler branches"
(ambiguous) whenever more than one sampler was found, even though every
branch used the same pair, so the workspace inspector fell back to the
legacy parser and the image could not be paired.

When every branch resolves to identical texts and stored recipes, the
result is now that pair: state resolved, no sampler named (none alone made
the output), reason "Every sampler branch uses the same prompt pair", the
first branch's sides and provenance, and every branch kept in `candidates`
as evidence. A branch that differs, cannot be read, or is not resolved
keeps the result ambiguous. Both profiles share the rule; the default
profile still cannot read ConditioningZeroOut, so the zero-out grid stays
ambiguous for ingestion and keeps the scan's legacy-parser fallback.

Workspace facts: a resolved result without a named sampler over several
listed samplers merges facts per field, as an ambiguous one does (the
grid's shared seed/steps are known, the per-epoch model is ambiguous).

Tests that encoded the old behavior (identical branches => ambiguous):
- test_prompt_pair_resolver: test_multiple_samplers_are_ambiguous_even_when_texts_match
  -> test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair
  (plus a new differing-texts case that stays ambiguous);
  test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor (same prompt)
  -> ..._of_one_prompt_share_it_without_an_anchor (anchored cases unchanged).
- test_pair_scan: two samplers sharing one prompt now ingest the pair
  ("cat"/"blur") instead of the positive-only fallback.
- test_api_prompt_inspector, test_inspection_outcomes, test_pair_image_linking:
  their "ambiguous" fixtures now use samplers with different prompts.
No parity vector changes (f01/f09 branches are not all resolved).
2026-10-21 09:32:23 -07:00
Vito e9996996e2 feat(views): saved views and Favorites on Images and Library (#268)
* feat(views): saved-views store and API

Add a saved_views table (page images|library, trimmed 1-100 character
name, whitelisted JSON state of at most 4 KB, position, timestamps)
installed through the same validated-savepoint migration as image
favorites. It is optional in old backups and required in a migrated
restore stage; restore rejects a tampered definition, extra indexes and
any row that would fail write validation.

Routes under /prompt_manager/saved-views: GET lists views by position,
POST {page,name,state} returns 201 {success, view}, PATCH /{id} {name}
renames and DELETE /{id} removes, both 404 for an unknown id. The state
must hold exactly the page's toolbar and Refine-this-view keys (Images:
q, category, sort, source, folder, media, tags; Library: q, category,
sort, tags) with their types and sort/media values; anything else is a
400.

Refs #249

* feat(views): Save view, the SAVED VIEWS sidebar and opening a view

The shared toolbar gains the concept's Save view between sort and Filters
on Images and the Prompt library. It opens the concept's simpleInput
("Save this view", "View name", default "Search: <q>" or "My workspace
view", Cancel / Save); an empty name is "Saved view". A save posts the
page's full whitelisted state (toolbar value plus Refine filters) and
toasts "Saved view added to the sidebar.".

The shell adds the concept's SAVED VIEWS label and nav#saved-nav, loaded
with the shell and re-read after every save. Rows use the concept's
links() markup (search icon, name, no count) and link #/saved/<id>.
Opening one mounts its page with the saved state through the page's own
filters, so the toolbar and "Filters · N" (D44) show it; the h1,
breadcrumb and title are the view's name. A removed Source or Folder is
dropped to its default after discovery, so no listing read is rejected;
an unknown id re-reads the list once, then opens Images; a failed list
read offers Retry.

D16 is retired: images-concept and library-concept no longer map Save
view out. New deviations: D49 (no PROPOSED badge) and D50 (no Needs
review row); shell-concept maps Favorites out until Task 3 and compares
a saved row against the concept's. Browser fixtures now wipe
saved_views.

* feat(views): Edit rename/delete and the Favorites saved view

- Favorites is the first SAVED VIEWS row (star icon, name, real count).
  The count is the Favorites listing's own limit=1 total, follows the
  nav-count rules and is re-read after every confirmed star or unstar.
- #/favorites opens Images filtered to favorites. The workspace listing
  takes favorites=1: each selected root's favorites are read through the
  image_favorites primary key and matched against the cached scan with the
  toggle's case folding; a failed read is an error, never an unfiltered
  page. The cursor scope includes the flag.
- Saved Images state gains a required favorites boolean, so Save view on
  Favorites reopens the favorites.
- An open saved view's heading leads with Edit: "Edit saved view" with
  View name, Delete... (danger), Cancel and Save. Delete... confirms in the
  same dialog (never window.confirm). Toasts "Saved view renamed." and
  "Saved view deleted."; a delete returns to the view's plain page.
- shell-concept no longer maps Favorites out (Needs review stays, D50).
  Recorded as D51 in TESTING.md.

* fix(views): open stale saved views safely and finish the review fixes

Startup and restore now check only the saved_views structure (table,
columns, types, sizes, a JSON object state) and never the value rules,
so a row saved under older rules no longer stops the database from
opening. Reads normalize each view: missing keys take their defaults,
unknown keys are dropped, invalid values default and the name is made
displayable. POST and PATCH still validate strictly.

Only request validation (SavedViewInvalid) maps to 400; database and
internal errors are a logged generic 500. A saved view no longer
replaces the plain Images or Library return state, a toolbar change
while sources load keeps a saved Source and Folder, a state over 4 KB
is refused in the browser with its own toast, the delete confirmation
skips its failure toast after the page is gone, the Edit dialog reuses
the shared field helper, and quick star toggles coalesce into one
Favorites count read.

* test(views): cover leaving a pending saved view and add its coverage floor

A saved view left while the list or a rename is still pending leaves
the next page alone; the shell drops two unreachable heading guards.
workspace/saved-views.mjs gets its frontend coverage baseline entry;
no existing floor changes.

* fix(views): a corrupt saved-view state no longer blocks startup

At startup a saved_views row whose state is not a JSON object is
logged as a warning by id only and left in place. The listing and
rename skip it, so its #/saved/<id> link behaves like a missing view.
Restore stays strict and still rejects a backup holding such a row.
2026-10-21 09:10:18 -07:00
Vito 762669758b feat(history): concept gallery cards, history comparison and CSS retirement (#266)
* feat(history): concept shared gallery

The history page's gallery is the concept's section.history-gallery with its
panel heading (Shared gallery, <name> · same positive prompt) and the Images
media cards, followed by the recorded executions. A saved variant with no
images shows the concept's empty copy. Recorded as D47.

* feat(history): drop the shared gallery's Linked images caption (D47 ruling)

The concept's "Shared gallery" heading is the gallery's only heading: the
embedded collection's "Linked images" heading and "N loaded · N linked
images" count repeated it. mountImages gains caption: false and a
focusTarget; only the history page's shared gallery passes them, so the
Images page and the All images and Repeat run dialogs keep their caption.
The layout row and the selected-image side panel stay (TESTING.md D47).

* test(history): concept comparison for prompt history

history-concept.spec.mjs compares the Prompt history with the concept's
#prompt/alpine on the concept fixture at 1600x1100 and 390x844, both
themes and motion settings, on Chromium and touch, with its behaviors
in both documents and a mutation check. inventoryOf gains a history
section; mapHistoryConcept applies D24, D35, D45, D46, D47 and the
approved Show archived button, laying the concept out at runtime.

Corrections it demonstrated: each compared negative mounts in a
display: contents child of the concept's .compare-item (D25), and the
concept fixture now links images oldest first and counts one pair use
per image, so newest-first reads and lane run counts match the concept.
history-canonical-comparison.mjs uses the concept's selectors for
production and drops its closed polish entries.

* feat(history): drop the Linked images caption from the history dialogs (D47 ruling)

The maintainer extended the D47 trim to the history page's two media
dialogs. All images for this positive and Repeat run are named by their
own dialog title, as in the concept (app.js a1282-1304), so their
embedded collection no longer shows the "Linked images" heading or the
"N loaded · N linked images" count. A stranded focus returns to the
dialog's close button. The Images page keeps its caption.

The history concept comparison now records each dialog's caption count
in both documents, and TESTING.md D47 covers the dialogs.

* refactor(workspace): retire library.css and the interim page guards

Every page is rebuilt, so the rules that waited for PRs D, E and F go.
library.css is deleted with its link; the overlay is now the only
stylesheet after concept.css.

Deleted because the concept already draws them: the .library-grid
tracks and breakpoints, the history .row-actions flex row and button
size, summary cursors, h2 margins, and the sender and Recorded run
dialogs' colors and border. Deleted because they had no effect:
min-inline-size on block children, a .recorded-run-dialog pre limit,
the general .prompt-inspector margin (now scoped to the Recorded run
dialog, the only mount point that is not display: contents) and a 700px
padding a later rule overrode. The duplicated sender and Recorded run
dialog guards are merged into one rule each.

Kept with a marker: windowed Library rows (D3), the shelf states (D40),
the history panel's wrap, archived-lane and coarse-target rules (D45),
and under the new D48 the production surfaces the concept never shows:
recorded executions and the Recorded run dialog (#209, D26), the
Archive negative confirmation (#235) and the prompt sender. They now use
the concept's --green and --red tokens instead of private copies with
the same values.

The workspace browser specs on Chromium and the concept specs on touch
pass unchanged. TESTING.md records D48 and closes the role-block gap.

* refactor(workspace): drop the dead embedded caption and tighten the review guards

- images.mjs: an embedded collection never has a caption (D47), so the
  caption option, its "Linked images" heading, the embedded count string
  and the heading arm of the focus fallback are removed; callers no
  longer pass caption:false. Behavior is unchanged.
- workspace.css: delete the never-applied .recorded-run-dialog::backdrop
  rule (the concept's .ca-tools dialog::backdrop always won) and note that
  the D3 row's min-inline-size: 0 is load-bearing for the concept's
  <=700px 1fr track.
- concept-css test: the waiting-rule guard is case-insensitive and is
  proven against the exact retired comments and the kept provenance labels.
- history-concept spec: function replacement for the singular run label,
  and the DOM mutation removes Compare negatives by role.
- D47 markers sit where the caption trim happens.
2026-10-21 05:16:38 -07:00
Vito 1ff2ddce05 fix(inspector): fall back to the positive's category and tags for recipe-linked images (#267)
* fix(inspector): fall back to the positive's category and tags for recipe-linked images

Maintainer ruling D39 (2026-10-21): a recipe-linked image shows the recipe's
own category and tags when set, otherwise the linked positive prompt's, each
field on its own and without merging tag sets. Set category (#259) and the
Images category and Tags filters work on the positive, so the panel now agrees
with them.

The inspection library replaces metadata_scope with category_scope and
tags_scope so the category chip's hidden scope prefix (D21) and the tag chips'
filter-button rule (#260 D2) follow the value actually shown. History's media
inspector uses the same shared rule. The concept fixture's positive_links
workaround is gone: the comparison now matches the concept without it.

* test(inspector): assert the D39 fallback tag chips in their saved order

* fix(inspector): treat Unicode whitespace as a blank History recipe category

* test(inspector): cover mixed-scope panels, invalid library scopes and the skipped positive tags query
2026-10-21 04:18:28 -07:00
Vito 10a4847a6b feat(history): concept heading, negative variants and compare (#265)
* feat(history): concept heading and summary

The Prompt history page takes the approved mock's heading and summary
surface (docs/concepts/workspace/app.js renderHistory a591-598, #249 F1):

- Heading description is "Prompt history · one positive, one shared
  gallery"; the actions stay "← Prompt library", "Edit metadata" and
  button.send "Send to ComfyUI" (aria-label "Send prompt pair to ComfyUI:
  <title>"), on one row at 1600. Send is disabled only while the selected
  pair's roles are loading (D24).
- The summary is article.surface > span.chip category, the positive and
  selected negative prompt blocks, then .card-meta "N tracked runs · N
  images · N negative variants". Tracked runs come from the recorded
  history window's recorded_run_count (hidden until a read completes,
  kept while a refresh is in flight); images and variants from the
  saved positive's image_count and variant_count.
- The summary's copy/send actions are named after the prompt, as in the
  concept ("Copy positive prompt: <title>"); mountPromptBlocks gains
  setTitle so an in-place metadata edit renames them without a remount.
- mountRecordedHistory gains an optional onRecordedCount callback.

Selector mapping:
- .library-surface (history summary)  -> .library-history article.surface
- .library-surface .library-chip      -> article.surface > span.chip
- "Copy|Send <role> prompt[ to ComfyUI]" in "Saved prompt texts"
                                      -> "...: <title>"
- heading p.muted "One positive prompt. Reusable negative variants. One
  shared gallery." -> "Prompt history · one positive, one shared gallery"
- Unchanged: region "Saved prompt texts", .library-variants,
  .library-shared-gallery, .recorded-history (still .library-surface).

Tests: workspace-history "history heading actions stay on one row at 1600
and are enabled after load" and "summary meta uses recorded runs" (both
failed before), a prompt-blocks setTitle unit, and the mapped selectors in
workspace-visual, workspace-library-editor and the canonical comparison.

* feat(history): concept negative variants panel

The history's Negative variants panel is the concept's (app.js a598,
style.css 1059-1154): section.surface.timeline-panel with the panel
heading (h2, p.small "Choose a red dot to change the negative and filter
this gallery."), row actions Compare negatives (aria-pressed; its section
is Task F3), + Add negative and the approved Show archived; a .timeline of
.positive-lane and .variant-lane rows (--lane-offset 38 + 47i) with the
"−" .variant-dot, "<name> · N runs" and a .run-dot when the pair's
run_count > 1; and .timeline-lower with the legend and All images for
this positive.

D26: All images for this positive opens the concept dialog with the
embedded, bounded (Load more) media gallery instead of filtering the
shared gallery. The run dot opens Recorded run for the pair's newest
recorded execution, else a Repeat run dialog with the pair's newest
linked image. Backing data:
- library variants carry prompt_pairs.run_count;
- GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest
  reads the newest run in the positive's exact raw-text scope whose
  current-epoch saved-pair hint still resolves to that pair (the
  get_history_run hint rule), inside the fenced history snapshot.
- workspace-dialogs gains openContentDialog (onClose releases the body).
- mountRecordedHistory returns openRun.

D45 (new deviations):
- a dot is named "Select negative <title>" when no other loaded lane has
  that title, else "Select negative variant N", so names stay unique;
- the bounded "N of M saved negative variants" count stays a visually
  hidden status; "Saved combinations, not an execution timeline." is gone;
- archived lanes (#235) are concept lanes marked .library-variant-archived
  at .6 emphasis with their Restore button; coarse dots keep a 47px
  target; the heading, legend row and All images button wrap at 390px
  and 200% zoom.

Retired CSS: .library-branches, .library-positive-lane,
.library-variant-lane, .library-variant-dot, .library-variants,
.library-panel-heading, .library-variant-footer, .library-variant-summary.

Selector mapping:
- .library-variants -> .library-history .timeline-panel (region "Saved
  negative variants" unchanged)
- .library-branches -> .timeline; .library-positive-lane -> .positive-lane
- .library-variant-lane -> .variant-lane; .library-variant-dot -> .variant-dot
- .library-variant-archived and .library-variant-restore unchanged
- .library-variant-footer / button:last-child -> .timeline-lower / button
- .library-variants > p.muted -> .timeline-panel .panel-heading .small
- "Select negative variant N" -> "Select negative <title>" for titled
  pairs (history, native library, inspector, visual and real-host
  fixtures); untitled pairs unchanged
- lane text "· N linked image(s)" -> "· N run(s)"
- All images for this positive: aria-pressed filter -> dialog "All images
  for this positive"; tests that used it for the whole gallery select the
  lane linking the image, or read the dialog
- variant panel row actions gain Compare negatives first

Tests: new workspace-history "variants panel matches the concept", "run
dot appears only for pairs with more than one run and opens its run",
"all images dialog lists every image for the positive"; new
workspace-library-editor "show archived and restore still work" (all four
failed before the change). Python: variants carry run_count, newest pair
run (DB and route). Unit: content dialog. The history fixture counts two
canonical pairs and links one image to Clean geometry.

* feat(history): compare negative variants

Compare negatives now opens the concept's comparison (app.js a599-616,
style.css 1155-1179) as a section.surface right after the timeline panel:
h2 "Compare negative variants", p "Baseline compared with <name>. Choose
another red dot to change the comparison.", a .compare-grid of two
.compare-item negative blocks (baseline, selected), p.small "Added terms",
then span.diff-added "+ term" per added term or p.small "No added terms.".
Choosing another red dot updates the same section in place; pressing
Compare negatives again removes it.

D25: the baseline is the oldest active variant; with no selection the
selected side falls back to it, as the concept's v does. The pure
addedTerms(base, selected) in history-state.mjs is the concept's rule:
comma split, trim, drop empty terms, exact case-sensitive difference that
keeps the selected order and repeats.

D46 (new deviations):
- lanes are newest first, so the baseline is the lowest-id active variant,
  not the first lane; while the variant page has more, the oldest is not
  loaded and the section says "Load more negative variants to reach the
  baseline." instead of guessing;
- the two blocks are named "Baseline" and "Selected" (regions and copy/send
  actions), so no name repeats on the page;
- Compare negatives is disabled when there is no active variant.

Selector mapping: none changed; the section is new
(.library-history .timeline-panel + section.surface).

Tests: unit "added terms split, trim and exact-diff like the concept";
workspace-history "compare toggles, follows the selected dot and stacks at
390" and "compare waits for the page holding the oldest active negative"
(all failed before the change).

* test(history): guard the newest-pair route and record D45/D46

TESTING.md lists the Prompt history page's spec decisions (D24-D26) and
its new deviations D45 and D46 beside D23 and D40-D44, notes under D46
that production opens on the newest lane while the concept opens on its
baseline, adds GET /prompt_manager/library/{positive_id}/history/pairs/
{pair_id}/newest to the history route list with its guards, and documents
the detail variants' run_count.

Tests: the newest-pair read skips a stale reference_epoch row, stops at
16 stale-hint candidates (17 newer stale rows hide an older valid run and
report {run: null}), and rejects a negative-only or deleted positive; the
route answers 404 for an ineligible positive, 503 storage-unavailable for
storage failures without leaking details, and 409 history-changed for a
restore during the snapshot.

* fix(history): run-dot touch target, unique dot names and dialog cleanup

- D45: under pointer: coarse the 12px run dot gets a 44px ::after target,
  and while a run dot shows the lane text wraps within 139px before it.
- D45: dot names compare trimmed titles and never use a title that reads
  like a fallback "variant N", so names stay unique.
- All images and Repeat run share one dialog-scope slot: each open
  replaces the previous scope instead of adding a cleanup per open.
- A run-dot lookup that settles after the view went stale opens nothing.
- Retire the unused .library-main .library-chip rule and the stale
  "Saved combinations" comparison note.

Tests (workspace-history, Chromium and touch): the coarse run-dot target
and lane width; a failed newest lookup shows the toast and Repeat run;
Recorded run that cannot open (recorded history failed) falls back to
Repeat run; an archived pair's Repeat run reads archive=archived; Compare
negatives is disabled with no active variant; dot names stay unique.

* fix(workspace): one inert handle for aborted history and prompt-block mounts

An aborted parent no longer gets a separate never-called stub per method:
mountRecordedHistory returns its hoisted openRun (it answers false before
reading later state), and mountPromptBlocks returns one shared no-op for
update, setState, setTitle and dispose. This keeps the history.mjs and
prompt-blocks.mjs function-coverage floors (98) without lowering them.

Tests: test:coverage (Chromium and touch, 922 workflows) passes for all
61 files; it failed before on history.mjs 94.44 and prompt-blocks.mjs
93.75 function coverage.
2026-10-21 02:25:25 -07:00
Vito 3476f8ede9 feat(inspector): concept comparison, Show in folder and toast placement (#264)
* test(inspector): concept comparison for the panel and viewer details

Compare the Images side panel and the viewer's full details with the
concept (1600x1100 and 390x844, both themes): structure, tokens, geometry
and controls, plus copy, the acknowledged pair send, the tag chip filter
and Prompt history. mapInspectorConcept applies only D3, D20, D21 and D22;
a mutation test shows the comparison notices a changed role label and a
removed footer.

The comparison showed the role copy and send names lacked the concept's
": <title>" subject, so the inspector now passes its title to the prompt
blocks. It also showed that a recipe-linked image shows its recipe's own
category and tags (#224), which the concept fixture's recipes lack; that
is recorded as D38, and the fixture's new positive_links option links the
compared image to its positive alone.

* docs(inspector): renumber the recipe-category decision to D39

* feat(images): local-only Show in folder route

POST /prompt_manager/images/reveal takes the listing identity
{root_index, root_scope, path} and opens this computer's file manager at
that file. It answers 403 not-local unless the client is loopback, no
proxy header is present and Host/Origin name this computer; the scope is
checked with _gallery_scope_error (409) and the path resolves only inside
that root to a regular media file. Openers run as argv lists with an
absolute executable, no shell and DEVNULL streams (D38).

* feat(inspector): Show in folder after the File & provenance path

The viewer's File & provenance offers "Show in folder" right after the
Path value for a listed file only, POSTs its listing identity and toasts
the outcome (opened, not-local, unavailable or failed). The browser test
server stubs the opener; TESTING.md records the decision as D38.

* fix(images): keep the discovery alert clear of the Rescan toast

A rescan that leaves the Images discovery alert visible could show its
fixed bottom toast over the alert on a short window. The concept's toast
keeps its placement; the production-only alert now scrolls into view only
as far as needed, with a 96px block-end scroll margin (D42).

* docs(inspector): concept panel and viewer details

* fix(images): open Windows folders through the shell API, not an Explorer command line

Explorer splits its own command line on commas, so a gallery path such as
output\a,\Windows\System32\calc.exe,b\x.png escaped the roots through
[explorer, "/select,", path] (security review H1). Windows now selects the
file with ILCreateFromPathW and SHOpenFolderAndSelectItems, and on any shell
failure falls back to os.startfile on the folder. No process is started.

* fix(images): re-check, isolate and reap the Show in folder opener

- re-check that the real path is still a regular file inside its root
  right before launching; a changed target answers 404 (review L4)
- run openers on a dedicated single worker, not ComfyUI's default
  executor, and make dbus-send wait for the FileManager1 reply
  (--print-reply --reply-timeout=2000) so a missing service falls back
  to xdg-open (L1)
- reap launched processes on a daemon thread, give them a minimal
  environment with a fixed system PATH, and use /usr/bin/open on macOS
  and dbus-send/xdg-open resolved only on that PATH (L5)

* fix(images): accept Show in folder only as a same-origin JSON request, one at a time

- require an Origin equal to the request's own scheme://host:port and,
  when sent, Sec-Fetch-Site: same-origin; refuse every forwarding-style
  header (Via, X-Forwarded-*, X-Original-*, *-Client-IP, CF-Connecting-IP
  and the rest) as remote (security review M1, L2)
- answer a non-JSON body with 415, so a cross-origin request always needs
  a CORS preflight (L2)
- single flight plus a 1 s cooldown per process: 429 code busy (L1)
- path resolution errors such as ENAMETOOLONG are a JSON 404, and neither
  that nor a start failure logs the client's path at error level (L3)

* fix(inspector): say the folder is already opening on a busy Show in folder

A 429 busy from the reveal route now toasts "The folder is already
opening." The browser workflow checks that the reveal POST carries the
page's own Origin and an application/json body, and TESTING.md D38 records
the tightened route, the toast table and the accepted residual risk of a
header-less local proxy.

* test(inspector): compare the viewer details under a coarse pointer and prove its mutations

The touch project measured production's D34 40px disclosure targets against
the concept's fine-pointer summaries. The concept's plain-chip copy now also
carries the recorded D34 coarse rule, so both documents are measured under the
same emulation. The open File & provenance is compared too (D38 Show in folder
and D20 Download original mapped), and the mutation check now removes the
viewer's pair actions and Show in folder.

* refactor(inspector): attach Show in folder to the Path value it follows

The button was placed on the second fact row by position; the Path dd is now
built by the caller and kept, so reordering the facts cannot move it.

* test(inspector): let the uncertain-file send check fail again

Inspector send buttons are named "... to ComfyUI: <title>", so the anchored
regex could never match and the no-send assertion always passed.

* fix(images): run the Windows folder fallback inside COM and keep desktop session variables

Show in folder's os.startfile fallback now runs inside the same
CoInitializeEx/CoUninitialize bracket as the shell selection, as ShellExecute
callers should. The Linux opener keeps XAUTHORITY, DESKTOP_SESSION,
XDG_SESSION_DESKTOP and XDG_CONFIG_HOME, so a file manager can reach the X11
display under GDM or SDDM and honour the user's mimeapps.list.

A Windows-only test calls the real ILCreateFromPathW and ILFree on a temp file
and pairs CoInitializeEx with CoUninitialize through the module's helper,
without SHOpenFolderAndSelectItems, so no window opens on CI.

* fix(images): scroll to the rescan's discovery alert only when the toast band covers it

D42 scrolled the alert into view after every rescan, so a user scrolled down
the grid was sent back to the top. It now scrolls only when the alert
intersects the toast's band (its scroll-margin-block-end).

* refactor(inspector): name prompt actions as PR #263 does

The title option keeps its name, default and output ("Copy positive prompt:
<title>") and now uses #263's actionName helper; the region-name arrow no
longer shadows the title option.

* test(inspector): the comparison opens Filters by its D44 active-count name

After a tag chip applies its filter, the shared toolbar's Filters button reads
"Filters · 1" (D44, #263), so the comparison opens it by that name.

* fix(images): POSIX opener paths stay absolute under Python 3.13 on Windows

Python 3.13 on Windows no longer counts "/usr/bin/x" as absolute, so the
faked-Linux reveal tests failed on windows-latest py3.13 (the Linux and macOS
openers are POSIX paths whatever host evaluates them). Use posixpath.isabs for
those lookups.
2026-10-21 00:53:04 -07:00
Vito 0a7b0238ca feat(library): concept prompt library cards, toolbar and caption (#263)
* feat(library): concept toolbar and caption

The Prompt library shelf uses the shared concept toolbar (search, category,
sort, Filters): sorts are Recently added / Name A-Z / Highest rated, then
Oldest first; the Tags filter moves to "Refine this view" (D14) and Refresh
library is removed (D15), with Retry kept and re-reading failed categories.
The caption is the concept's .section-caption, "N positive prompts · M
negative variants" over the filtered set from variant_total.

Library-embedded media cards keep the favorite star working: the Library
gallery read already reports favorite (one batched lookup per page, now
covered by a test), and star answers are delivered to every mounted embedded
gallery, so a star toggles, repaints and survives a reload. The checkbox and
selection bar stay Images-only.

* feat(library): concept prompt cards with real runs and last use

The Prompt library shelf renders the concept's article.prompt-card:
.card-art (up to two previews), .card-title with a title button and the
category chip, the positive prompt block, .card-meta ("− N negative
variants", "N runs", "N images"), .tags, and .card-footer with the rating
and last-use line and the positive send plus Explore history buttons.

D23: "N runs" is the parent's run_count; the footer reads Used today /
yesterday / N days ago / <Mon D> in the viewer's local calendar days, or
"Not used yet"; stars carry a visually hidden "Rated N out of 5" and an
unrated parent shows no stars. The positive is clamped to two lines while
copy and send use the full text. Long titles wrap inside the title row
and the category chip never breaks mid-word. Card actions are named after
the prompt ("Copy positive prompt: <title>") through a new title option on
the shared prompt blocks. The production .library-card CSS is removed and
the row estimates follow the clamped card height.

The shelf error now reads "Prompt library could not be loaded. Retry."
(Refresh is gone). TESTING.md records D23, the production-only shelf copy
(D40) and the concept search placeholder kept on the Library (D41).

* test(library): concept comparison for the shelf

library-concept.spec.mjs compares the Prompt library shelf with the
immutable concept on the concept-shaped fixture: heading, toolbar,
caption, grid and the six cards (art, title row, prompt block, meta,
tags, footer) at 1600x1100 and 390x844 in both themes and motion
settings, the 3-column grid at 1800x1100, and the behaviors in both
documents (title and Explore history open history, the footer send
carries the positive only, copy toasts "Prompt copied.", the "neon"
search and the Landscapes category). A mutation test proves the
comparison notices a footer padding change and a removed variants chip.

mapLibraryConcept applies only recorded deviations: D14, D16, D23 (title
min-width, singular variant chip, spoken rating, the two-line clamp the
concept's samples exceed at 390px), D35, D36, D40 and the new D42 (tag
chips in the store's alphabetical order). Each re-laid-out part is
checked against the concept page with that deviation applied at runtime.

Corrections the comparison demonstrated:
- the fixture creates the concept's prompts last to first, so Recently
  added lists them in the concept's order;
- the shelf no longer ends with the legacy "Current prompt library"
  link; Overview's legacy cards keep it, as with "Current gallery".

The shared inventory gains the Library section, and reflow/withoutItems
move into concept-inventory.mjs for both comparison specs.

* test(workspace): reach the legacy Library from Overview, not the shelf

The shelf no longer links the legacy Library (#249 E3). The shell
destinations test now expects no legacy link on Prompt library and
asserts Overview's "Open existing prompt library" card still links
/prompt_manager/admin and that the page loads.

* feat(library): Filters shows its active count; final review follow-ups

- D44: the shared toolbar's Filters button reads "Filters · N" while N
  "Refine this view" filters are active (Library: Tags; Images: Source,
  Folder, Media, Tags), so a Browse-set Tags filter is visible on the
  shelf. The text is the accessible name. Unit and browser tests cover
  Browse -> "Filters · 1" -> Clear -> "Filters" and the Images counts.
- Renumber the alphabetical tag chips deviation D42 -> D43 (D42 is #264's).
- The caption count is a role=status region written only when it changes
  and kept shown while the shelf reloads.
- usedLabel names the year for a use in another calendar year (D23).
- prompt-blocks: same `title` option API as #264 (`subject` suffix); the
  region-name helper's parameter no longer shadows it.
- Scope library.css .row-actions rules to the history panels; fix stale
  CSS and spec comments.
- Retry re-reading a failed category list and the no-op Apply/search
  paths are covered by a browser test.
- TESTING.md: D23 amendments (year, button vs link trade-off), D44, the
  legacy Library link wording.
2026-10-20 23:26:05 -07:00
Vito 00bda8f442 feat(inspector): concept prompt blocks, side panel and viewer details (#260)
* feat(workspace): concept prompt blocks

Shared prompt blocks (Images panel, viewer details, Library cards and
saved texts, recorded runs) now render the approved mock's markup
(app.js promptBlock a298-302, concept.css s550-588), #249 D22:

- section.prompt-section.<role>[aria-label="<Role> prompt"] >
  .prompt-heading (span.role-label "+ Positive prompt" / "- Negative
  prompt"; .prompt-actions: copy button.icon-btn "Copy <role> prompt",
  then send button.send.icon-btn "Send <role> prompt to ComfyUI") +
  p.prompt-text.
- No per-block source line; a file's evidence moves to the panel badge
  (D2) and "File & provenance" (D3). Recorded runs keep every field's
  source in "Frozen field provenance".
- Empty text reads "No negative prompt (explicitly empty)" and still
  sends an explicit clear. Absent, ambiguous and unresolved roles render
  p.prompt-text.muted with the existing status strings and no actions;
  unknown role names render nothing and cannot be sent or copied.
- Copy shows the shared toast "Prompt copied."; without a clipboard it
  opens the concept's "Copy prompt" select-to-copy dialog.
- The data-send-role / data-copy-role contract is unchanged.

The D22 interim overrides (26px actions, prompt-text margins, copy
status, source line) are retired so the concept's rules apply; the
action buttons measure the mock's 31x31. pre.prompt-text styling stays
only for the Library editor's Archive dialog and frozen field
provenance, which keep their own markup until their page rebuilds.

Test selector mapping:
  .prompt-block / .prompt-positive  -> .prompt-section(.positive)
  .prompt-block-heading h3          -> .prompt-heading span.role-label
  region pre / pre.prompt-text      -> p.prompt-text
  "Empty negative prompt"           -> "No negative prompt (explicitly empty)"
  "Send <role> prompt"              -> "Send <role> prompt to ComfyUI"
  .prompt-source / copy status line -> removed (toast or Copy prompt dialog)
  copy button 30x26                 -> 31x31 (measured in the mock)

* feat(inspector): concept side panel

The Images side panel is the concept's aside#image-inspector.detail-panel in the
order of app.js imageDetails: plain cover, heading with the format chip, filename,
the D21 evidence badge and category chip, prompt blocks, tag chips, the six facts
and the footer (Send to ComfyUI pair, Prompt history). Provenance, raw records and
downloads stay in the viewer's full details. Positive-scope tag chips set the
Images Tags filter to that tag.

* fix(inspector): mark the side panel's overlay rules with their D2 decision

The overlay lint (tests/vendor/concept-css.test.mjs) requires a /* D<n> */
marker on every workspace.css rule that overrides a concept selector; the
three .detail-panel rules from D2 lacked one.

* feat(inspector): full details in the viewer

The viewer's #viewer-details renders the concept's imageDetails(i, true):
the side panel content without the cover in a class-less wrapper, then
"File & provenance" and "Workflow & raw metadata" (pre[tabindex=0] > code
and the two downloads with their unavailable reasons), then the pair send
("Send to ComfyUI", labelled "Send prompt pair to ComfyUI: <title>") and
Prompt history buttons. One renderer now serves both variants.

Provenance wording (maintainer, 2026-10-20): the six per-fact
"<Fact>: Configuration evidence · <source>" lines and FACT_SOURCES are
gone; the concept's "Prompt evidence: <badge>" line carries the D21 badge
in lower case and is left out without a badge. The honest facts stay
(current-file note, Source, Path, Size, Dimensions, Format, Media type,
Modified, occurrence, association, Download original). An unverified
format reads "PNG extension", no stray leading dot.

inspector.css and images.css are deleted; only rules with a live
consumer moved to the overlay under #249 D3 (inspector wrapping, the
inert aria-disabled download, the narrow Images container, the Library
and recorded-run prompt blocks, the Archive negative's prompt-block, the
prompt sender) plus a D20 wrap rule for the viewer's buttons at 200% zoom.

D1 review follow-ups: library.css no longer restyles recorded-run
p.prompt-text.muted (m2); the copy fallback has a seam and unit tests for
a missing clipboard and a refused write (m3); a stale test title is
renamed (m4); the fallback trigger falls back to the same-role button
when the clicked one was detached by a re-render (m7); TESTING.md records
the D20 and D22 deviations (m1).

Selector mapping in tests: .file-inspector-inner -> .panel-heading,
.generation-facts -> .meta-grid (first), .file-facts -> details .meta-grid,
.file-name -> #viewer-details > div > p.small, .file-inspector-footer ->
#viewer-details, viewer "Send prompt pair" -> /^Send prompt pair to ComfyUI: /.

* test(comfyui): real-host specs read the concept prompt blocks

library-sender: an explicitly empty saved negative now reads the concept's
"No negative prompt (explicitly empty)" (D22). The prompt-sender spec's
panel and viewer selectors (complementary "Selected image details", the
card image opening the viewer, .meta-grid facts, the labelled pair send)
moved with the D3 commit.

* test(comfyui): the frozen run's prompt regions read the concept prompt text

* fix(inspector): coarse viewer-detail targets and Prompt history on the shown route

- Prompt history closes the viewer and, when its target is the route
  already shown, re-renders it as the concept's go() does (navigateTo
  plus a router refresh event), instead of doing nothing.
- The viewer details' disclosure summaries and "Download original" keep
  40px targets on coarse pointers (#249 D34), as TESTING.md promises.
- Drop the dead `.prompt-positive` rules and merge the two
  `pre.prompt-text` rules.
- TESTING.md records the side panel's D20/D21 deviations.
2026-10-20 20:15:21 -07:00
Vito 728db36ac3 ci: fix the frontend coverage timeout and the WebKit Load more race (#262)
* ci: give the frontend coverage job a 40-minute limit with measured headroom

The job runs every page workflow twice (chromium, then touch) on one
worker with V8 coverage, so it takes about twice a single-browser job.
Recent runs took 18m26s-24m57s and PR head 6359999 was cancelled at the
25-minute limit while still passing. Coverage, floors and commands are
unchanged.

* test(images): dispatch the production Load more press so the observer cannot pre-empt it

A pointer click scrolls the continuation row into view, which lets the
page's own infinite-scroll observer append the final batch and hide the
button before the click lands (WebKit, run 39512528039). Dispatch the
press as the concept side already does, after asserting the button is
visible and enabled; the count, card, toast and loading-row assertions
are unchanged.
2026-10-20 19:47:37 -07:00
Vito 5ded3cfd39 test(history): prove bounded close with events, not wall-clock bounds (#261)
The real-clock subtest of the bounded-close test asserted close(0.02)
returned in under 0.2 s; a slow macOS runner took 0.26 s. The failed
subtest then skipped its release and join, so its worker stayed blocked
holding a charge on the process-wide SHARED_CONTEXT_BUDGET, and the next
subtest saw 621 != 0. Not a production race: close(2) only returns True
once the worker has exited and released its leases.

Prove boundedness with events instead: close() must return False while
the in-flight write is still blocked (it has not left its fake write).
Keep the exact upper bound only under the manual clock, where it is
deterministic. Release and join each subtest's worker in a finally so a
failure cannot leak a charge into the next subtest. The commit-gate test
gets the same treatment: submit() and close(0) must return before the
blocked invalidation and commit finish, instead of a 0.1 s bound.
2026-10-20 18:01:34 -07:00
Vito 2b8126b7be feat(images): card selection checkbox and selection bar (Set category, Export metadata) (#259)
* 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.

* 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.

* 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.

* 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.

* 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.

* 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 16:04:15 -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
148 changed files with 26549 additions and 2092 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
+6 -1
View File
@@ -26,7 +26,12 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 25
# This job runs every page workflow twice (chromium, then touch) on one worker with V8 coverage on, so it
# takes about twice a single-browser job. Measured 2026-10-20: whole job 18m26s-24m57s (main 5ded3cf
# 22m15s; PR head 6359999 was cancelled at the old 25-minute limit while still passing), coverage step
# alone 20m55s-22m17s+, against ~12-15 minutes for the firefox/webkit jobs. 40 minutes leaves headroom
# for suite growth while still stopping a hung run; re-measure if a run passes 30 minutes.
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
+814 -4
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:
@@ -194,9 +225,27 @@ managed host with both renderer startup configurations.
The shared inline and collection inspector reads the current rooted file's supported
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
content without the cover, then "File & provenance" (the honest file facts, the
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
two downloads), then the pair send and Prompt history buttons. D22 words an empty
positive explicitly and makes confirmed prompt text focusable.
`tests/browser/inspector-concept.spec.mjs` compares both surfaces with the concept
(see the D20–D39 notes under Images).
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
response limits, source changes, nested sending, matched dark/light and mobile
geometry, and the loaded-collection viewer. 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
@@ -305,6 +354,755 @@ 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` and the production overlay `workspace.css`
last; every page stylesheet that waited for its page PR is retired.
`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; by convention such rules carry a
marker too.
PR D deleted `images.css` and `inspector.css`, and PR F-b deleted `library.css`, the
last page stylesheet (test `the retired shell, vocabulary, editor, Images, inspector and
library stylesheets stay deleted`). `the overlay keeps no retired Images page or
inspector rules` keeps the replaced card, filter, toolbar, preview, facts and inspector
rules out, and `the overlay keeps no rule waiting for a page rebuild` keeps out the
interim guards and the history page's private role colors. Every rule that stayed has
a live consumer and a marker: the Library rows windowed (D3) and the shelf's states
(D40), the history panel's wrap, archived-lane and coarse-target rules (D45), and under
**D48** the production surfaces the concept never shows, drawn in the concept's tokens
(`--green`, `--red`, `--ca-*`): recorded executions and the Recorded run dialog (#209,
D26), `pre.prompt-text` for stored text in the frozen field provenance and the Archive
negative confirmation, whose `section.prompt-block` (#235) keeps its rules too, and the
acknowledged prompt sender (spec §2.2). Rules that repeated the concept
(the `.library-grid` tracks and breakpoints, the history `.row-actions` flex row and
button size, `summary` cursors, `h2` margins, the sender and Recorded run dialogs'
colors and border) or had no effect (`min-inline-size` on block children, a
`.recorded-run-dialog pre` limit, a 700px padding that a later rule overrode) were
deleted; the workspace browser specs on Chromium and the concept specs on touch prove
the pages unchanged.
**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 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.
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
the concept's `links()` rows: the search icon, the view name and no count, linking
`#/saved/<id>`. Opening one shows its page with the saved toolbar value and Refine
filters, and the view's name as the h1, breadcrumb and title. A value its page no
longer accepts (a removed Source or Folder) stays default; an unknown id opens Images.
A toolbar change made while the sources are still loading keeps the saved Source and
Folder until discovery settles. A saved view, like Favorites, never replaces the plain
Images or Library return state. Writes (POST, PATCH) validate strictly; a state over
4 KB is refused in the browser with "The view is too large to save. Shorten its
search, folder or tags and try again." Startup and restore check only the table's
structure and that each row's state is a JSON object of at most 4 KB, never the value
rules, so a row saved under older rules (a missing or retired key, a sort no longer
offered) never stops the database from opening: reads fill missing keys with their
defaults, drop unknown keys, default invalid values and make the name displayable
(`tests/test_saved_views.py`). A live row whose state is not a JSON object never
blocks startup either: its id is logged as a warning, the row is left in place, the
listing and rename skip it, and its `#/saved/<id>` link behaves like a missing view.
Restore stays strict and rejects such a backup ("Saved views contains invalid values"). Only request validation is a 400; any other failure is
a logged 500 with a generic message. The Favorites count re-read after starring is
coalesced to one read 300 ms after the last toggle.
`a saved view row matches the concept's` saves one view in both documents and
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
reopening, the defaults that replace rejected values, and failed reads and saves.
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
ruling, 2026-10-21). The comparison maps only that row out.
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
the concept's Favorites row (star icon, name, count). Its count is the Favorites
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
the nav-count rules (hidden until loaded, absent when its read fails, never a
placeholder) and is re-read after every confirmed star or unstar, on any page.
`#/favorites` is Images filtered to favorites, named "Favorites" (concept
`renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root
from `image_favorites` and matched against the scan with the toggle's case folding,
and a failed read is an error, never an unfiltered page. Unlike the concept, an
unstarred card stays on Favorites (showing ☆) until the next load, so the star can be
undone in place. Favorites neither restores nor replaces the Images return state.
Save view there saves `favorites: true`, so the view reopens the favorites. An open
saved view's heading leads with **Edit**: "Edit saved view" with "View name" and
Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and
toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the
same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`; its Cancel
closes the dialog without deleting rather than returning to Edit); Delete
removes the view, replaces the dead route with the view's plain page and toasts
"Saved view deleted.". Failures toast "The view could not be renamed. Try again." or
"The view could not be deleted. Try again."; a view another tab already deleted counts
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
route, star updates, Save view on Favorites, Edit, and the failure paths;
`tests/test_image_favorites.py` covers `favorites=1`.
- **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. Save view's dialog action reads
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
- **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),
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 (retired)**: Save view is the concept's own toolbar button, between sort and
Filters, on Images and the Prompt library (see D49). It saves the visible toolbar value
with the Refine filters; the comparisons no longer map it.
- **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.
- **D44** (#249 PR E): Filters reads "Filters · N" while N of Source, Folder, Media and
Tags are set in "Refine this view" (see the Prompt library deviations).
- **D20**: the side panel shows only the concept content; "File & provenance",
"Workflow & raw metadata" and the downloads are only in the viewer's details
(`#viewer-details`: the panel content without the cover in a class-less wrapper, the
two disclosures, then the pair send and Prompt history buttons). "File & provenance"
keeps the honest facts (current-file note, Source, Path, Size, Dimensions, Format,
Media type, Modified, the reported-location occurrence, the saved-association
sentence, Download original) and the concept's `Prompt evidence: <badge>` line in the
D21 badge's words in lower case, left out when there is no badge. Raw records keep
their names, representations and the two downloads with their unavailable reasons;
the viewer's pair send and history buttons may wrap at 200% zoom. Tag chips in the
viewer are plain (the concept's go to the Library). The details' disclosure
summaries and "Download original" keep 40px coarse targets (D34). Prompt history
closes the viewer and, when its target is the route already shown, re-renders it
(the concept's `go()`).
- **D20/D21 side panel**: the sticky panel scrolls inside the window
(`max-block-size` with `overflow: auto`, not the concept's `overflow: hidden`), so
long real prompts keep the footer reachable; an empty badge or tag `.tags` row is
left out (the concept always renders both); the footer, its pair send and Prompt
history appear only with resolved prompt roles or a saved link; a video file keeps
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
- **D38 Show in folder** (maintainer decision 2026-10-20, beyond the mock): in the
viewer's File & provenance the Path value stays text, followed in its `dd` by a
"Show in folder" button (`Show <filename> in folder`), only for a listed file
(`root_index`, `root_scope`, `path`), so never in the side panel or a recorded run.
It POSTs that identity as JSON to `/prompt_manager/images/reveal` and toasts:
| Result | Toast |
|---|---|
| Success | "Opened the folder on this computer." |
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
| 501 `unavailable` | "No file manager is available on this computer." |
| 429 `busy` | "The folder is already opening." |
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
The route is local-only. It answers 403 `not-local` unless all of these hold: no
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
`null`, another port or another scheme is refused; browsers send `Origin` on every
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
relative path; absolute, drive and `..` paths are 400), the scope goes through
`_gallery_scope_error` (409 `source-changed`), and the path resolves only inside
that root to a regular media file (403/404; a resolution `OSError` such as
`ENAMETOOLONG` is the same JSON 404, and the client's path is never logged at error
level). One reveal runs at a time per process with a 1 s cooldown after it
(`RevealGate`, 429 `busy`), on its own single worker (`reveal_executor`), never
ComfyUI's default executor. Just before launching, `utils/file_reveal.py` re-checks
that the real path is still a regular file inside the root (a swapped parent is
404). Windows starts no process: `ILCreateFromPathW` and
`SHOpenFolderAndSelectItems` select the file through the shell API (Explorer's own
command line splits on commas, so a path like `output\a,\Windows\...` must never
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
the shell API and `os.startfile`; the browser server's opener is a stub
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
closed disclosures as hidden, so the button needs no mapping there.
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
from a local client, and a non-browser client behind it can forge a matching
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
screen, at most once a second, and learn whether that file exists (which the
listing routes already tell it). Operators who expose ComfyUI that way accept this.
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
category when set, otherwise the linked positive prompt's, and its recipe's own tags
when it has any, otherwise the positive's; each field falls back on its own and the
two tag sets are never merged. Set category (#259) writes the positive's category and
the Images category and Tags filters match the positive's, so the panel now agrees
with them whenever the recipe adds nothing. The inspection response names each
field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or
null); the category chip's visually hidden prefix (D21) says "Recipe category: " or
"Positive prompt category: " from it, and tag chips are Tags filter buttons only when
they are the positive's (#260 D2). History's media inspector uses the same rule. The
concept shows the prompt's category and tags for every image and the concept
fixture's recipes have neither, so the inspector comparison needs no mapping for it.
- **D42**: the concept never shows a toast and a page alert together. Production keeps
the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below)
and, when an Import → Rescan toast leaves the Images discovery alert
(`.options-status`), scrolls that alert into view only as far as needed, with
`scroll-margin-block-end: 96px`, so it is never under the toast.
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
the toast's band at 1600 and 390 pixels and measures both boxes.
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
structure, cover, heading, filename, badges, prompt blocks (role labels, actions,
text), tag chips, facts, footer, the viewer's disclosures and pair actions, and every
offered control's name and minimum height. Each role's copy and send are named after
the prompt, as the concept's are ("Copy positive prompt: <title>"). The concept loads
all 24 fixture images first (D37). Behaviors: copy shows "Prompt copied." (or the
same "Copy prompt" dialog in both); the pair send previews both roles in the concept
and is applied and acknowledged by a test ComfyUI tab in production; the `cinematic`
chip sets the Images Tags filter (D21: the concept searches the word, which also
matches the untagged forest prompt's text); Prompt history goes to
`#/library/<positive id>` (the concept's `#prompt/<id>`). `mapInspectorConcept`
applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
viewer's plain tag chips, laid out by the concept page itself with its chips made
plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
click it (the viewer reads "<title> · <filename>"; Escape closes it); press a card's
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 inspector-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
### Prompt library cards and recorded deviations (#249 PR E)
The shelf uses the shared concept toolbar and caption (E1) and the concept's
`article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art`
(at most two previews), `.card-content` (`.card-title` with the title `button` and the
category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and
`.card-footer` (the rating and last-use line, then `.row-actions` with the positive
`button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`).
`tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and
the measured row heights on Chromium and touch.
Recorded deviations on this page:
- **D23**: real card facts. "N runs" is the parent's `run_count`. The footer reads
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>"
("Used <Mon D>, <YYYY>" for a use in another calendar year, so last year's date never
reads as this year's), counted in the viewer's local calendar days from `last_used_at` (a time ahead of the
browser reads as today), and "Not used yet" without one. Stars are `★` × rating with
a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words.
The meta chip reads "− N negative variants" (singular for one). The title and Explore
history are buttons that open `#/library/<id>`, as the concept's are; the trade-off
against the old links is that they offer no open in a new tab, middle-click or URL on
hover. The positive is clamped to two lines
(`.prompt-card .prompt-text`); copy and send use the full text. A long title wraps
inside the title row, and the category chip keeps whole words ("Uncategorized" for
none). A parent with one preview, or none, fills the art row; no preview reads "No
available image previews" (`.small`). Each card's actions add its title to their names
("Copy positive prompt: <title>", as the concept's do).
- **D40**: production-only shelf copy the concept never shows. A failed page reads
"Prompt library could not be loaded. Retry." beside "Retry prompt library" (no Refresh,
D15). A failed category list reads "Categories could not be loaded. Open the Prompt
library again to retry." (Retry re-reads it only when the shelf failed too). The
continuation keeps "Loading prompt library…", "No matching prompts. Try changing the
filters." (the concept: "No matching prompts."), "You’re all caught up.", "The saved
prompt is hidden by the current filters." and "Load more prompts".
- **D41**: the search keeps the concept's shared placeholder "Search prompts, tags,
filenames…" for parity with Images, although Library search matches positive text and
titles and saved negative text and variant titles, not filenames or tags (Tags live in
"Refine this view", D14).
- **D43**: a card's tag chips come in the store's alphabetical order (`ORDER BY t.name`,
as everywhere tags are read); the concept lists each sample's tags in authored order.
- **D44**: the shared toolbar's Filters button reads "Filters · N" while N "Refine this
view" filters are active, and plain "Filters" otherwise; the text is its accessible
name, so the count is spoken. The concept has no indicator, and without one a Tags
filter set by Tags & categories Browse was invisible on the shelf. The Library counts
Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is
not a Refine filter and is not counted. The caption's count is a `role="status"`
region, written only when it changes and kept shown while the shelf reloads, so
Load more and scrolling never re-announce it.
`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the
concept-shaped fixture, which creates the concept's prompts last to first so Recently
added lists them in the concept's order: heading, toolbar, caption, grid and the six
cards (art, title row, prompt block, meta, tags, footer) at 1600x1100 and 390x844 in
both themes and motion settings, and the grid and cards at 1800x1100 (3 columns).
Behaviors, in both documents: the title and Explore history open the prompt's history
(its `h1` is the title), the footer send carries the positive only (a bridge host page
records the roles production's preview asks for), copy writes the full positive with
"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D23 (the
title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the
singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the
concept's samples exceed at 390px, checked against the concept with the clamp rule
applied), D35 (singular captions), D36 (sorted categories), D40 (the continuation after
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open
existing prompt library") and the sidebar still link `/prompt_manager/admin`
(`tests/browser/workspace.spec.mjs`). `library comparison detects
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
Manual check (quick), after a browser refresh on Prompt library: each card shows the
title with its category chip on one line, two lines of the positive with copy and send,
"− N negative variants · N runs · N images" and the tags; the footer reads "★★★ · Used
today" (or the real date) with send and "Explore history"; Filters → Tags still narrows
the shelf, and the button then reads "Filters · 1" (D44), also after a Browse from Tags &
categories.
```sh
npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
```
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
variants panel and comparison (app.js `renderHistory` a591–616, the dialogs a1282–1304,
style.css 1059–1179). The summary is `article.surface` with the category chip, the
positive and selected negative prompt blocks and `.card-meta` "N tracked runs · N images
· N negative variants" (tracked runs from the recorded window's `recorded_run_count`).
The panel is `section.surface.timeline-panel` (region "Saved negative variants") with
Compare negatives, + Add negative and Show archived, a `.timeline` of `.variant-lane`
rows (`--lane-offset` 38 + 47i, the "−" `.variant-dot`, "<name> · N runs" and a
`.run-dot` for a pair counted more than once) and `.timeline-lower` with the legend and
All images for this positive. Recorded executions, frozen fields, send and archived
lanes keep their contracts. `tests/browser/workspace-history.spec.mjs` and
`workspace-library-editor.spec.mjs` cover them on Chromium and touch.
Spec decisions this page applies: **D24** (Send to ComfyUI is disabled only while the
selected pair's roles load), **D25** (Compare negatives compares a baseline with the
selected red dot, and lists the selected side's added comma terms) and **D26** (All
images for this positive is a dialog with its own bounded gallery; the run dot opens
Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the
pair's newest linked image, reading the archived gallery for an archived pair).
Recorded deviations on this page:
- **D45**: a variant dot is named "Select negative <title>" when no other loaded lane
shares that title (compared trimmed), else "Select negative variant N"; a title that
itself reads "variant N" always uses the lane's own number, so no two names repeat.
The bounded "N of M saved negative variants" count stays a visually hidden status,
and production's "Saved combinations, not an execution timeline." is removed.
Archived lanes (#235) are concept lanes marked `.library-variant-archived` at the .6
marker emphasis (their text stays at full contrast) with their Restore button. Under
`pointer: coarse` the variant dot keeps a 47px target and the 12px run dot a 44px one
(`::after` overlays). While a run dot shows, the lane text wraps within 139px before
it. The panel heading, the legend row and All images wrap at 390px and 200% zoom.
- **D46**: lanes list newest first (`ORDER BY pp.id DESC`), so the comparison's
baseline is the oldest (lowest-id) active variant, not the first lane; while the
variant page has more, the oldest is not loaded and the section reads "Load more
negative variants to reach the baseline." instead of guessing. The two blocks are
named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats.
Compare negatives is disabled while the positive has no active variant. The page
opens on the newest lane (existing behavior), while the concept opens on
`negatives[0]`, its baseline: the first comparison reads "Baseline compared with
<newest>" where the concept compares the baseline with itself. The history concept
comparison (PR F-b, F5) maps this.
- **D47** (#249 PR F-b): the shared gallery is the concept's `section.history-gallery`
(region "Shared gallery") with `.panel-heading` (h2 "Shared gallery", `span.small`
"<name> · same positive prompt") after the variants panel (and Compare), and
recorded executions (#209) follow it, as the concept has no recorded section. The
name is the lane's own name ("<title>", else "Negative variant N"), with " · Archived"
for an archived pair, and follows a rename. Its cards are the Images media cards: the
star wherever the read returns `favorite`, no checkbox or selection bar (Images only,
#259), the image opens the Viewer. A saved variant with no images shows the concept's
`.empty` "Saved variant · no runs yet." beside the grid (the concept puts it inside
`.image-grid`, which only holds positioned cards here). The concept's "Shared gallery"
heading is the gallery's only heading and names its region: the embedded collection's
"Linked images" heading and "N loaded · N linked images" count are removed, since they
repeated it (maintainer ruling, 2026-10-21). Two production pieces stay, which the
concept's gallery does not show: the layout row (Grid/List/Masonry and Thumbnail size),
because the legacy gallery let people change layout and density and nothing else on the
page offers it, and the selected-image side panel, the one shared inspector the Images
page uses (spec §3.3), so an image's details stay reachable without opening the viewer.
The same ruling covers the page's two media dialogs, All images for this positive and
Repeat run: as in the concept (app.js a1282-1304), each is named by its own dialog
title, with no "Linked images" heading or "N loaded · N linked images" count, and a
stranded focus returns to the dialog's close button (maintainer ruling, 2026-10-21).
The Images page keeps its caption. With no saved variant the page shows every image
of the positive under "All images for this positive, including unassigned images. …"
and no empty copy (a state the concept never reaches).
- **D48** (#249 PR F-b): production surfaces the concept never shows keep their own
overlay rules, drawn in the concept's tokens: recorded executions and the Recorded run
dialog (#209, D26), the Archive negative confirmation (#235) and the acknowledged
prompt sender (spec §2.2). With `library.css` retired, these rules live in
`workspace.css` (see "Concept shell and comparison method").
The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest`
(see "Chronological Library history"), and each lane's "N runs" is the pair's
`run_count` (see "Workspace data fields").
`tests/browser/history-concept.spec.mjs` (#249 PR F-b, F5) compares this page with the
immutable concept's `#prompt/alpine` on the concept fixture, at 1600×1100 and 390×844 in
both themes and motion settings, on Chromium and touch: the heading, the summary, the
variants panel, the comparison (Compare negatives pressed) and the shared gallery's
place and heading, all measured by `inventoryOf`'s `history` section with the concept's
selectors in both documents, with "Clean architecture" selected (the middle lane in
either order). The fixture links and records its images oldest first, so ids grow with
time as real runs do, and counts one pair use per image, which gives the concept's
"Quality baseline · 2 runs" and its run dot. `mapHistoryConcept` applies D24 (the four
recorded runs give the concept's own "4 tracked runs", asserted on both), D45 (the
visually hidden "3 of 3 saved negative variants" status, and its wrap rules applied to
the concept at runtime), D35 and D46 (the concept's lanes reordered newest first with
"1 run", laid out by the browser), D46's Baseline/Selected action names, #235's Show
archived button (added to the concept at runtime) and D47 (the gallery's body is the
Images collection, so its height is not compared). D25: each compared block mounts in a
`display: contents` child of the concept's `.compare-item`, so the item carries no
production class or margin. Behaviors are compared in both documents: a red dot changes
the negative block and the gallery, Compare toggles `aria-pressed`, All images opens a
dialog with the positive's four images, the run dot opens a dialog (D26: Recorded run
where the concept shows Repeat run), neither dialog has a collection caption (D47), the
heading send previews the selected pair, and
← Prompt library navigates. `history comparison detects mutations` injects
`.ca-tools .variant-dot { width: 20px }` and removes Compare negatives. The older
`history-canonical-comparison.mjs` stays for `workspace-visual.spec.mjs`'s recorded
executions (#209) checks; its production selectors are now the concept's.
```sh
npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
workspace-history.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. Each detail variant (active and archived) also carries
its pair's `run_count` (`prompt_pairs.run_count`, 0 when null), which the history lanes
show as "N runs" (#249 F2).
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
`{root_index, path}`. Both are idempotent and answer
`{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
@@ -487,10 +1285,20 @@ The versioned public reads are:
| Route | Meaning |
| --- | --- |
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` |
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
The newest-pair read takes no query. It answers 404 when the positive is not an
eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact
raw-text scope newest first and follows only a row whose `reference_epoch` is the
current epoch and whose saved-pair hint still resolves to that pair (the
`get_history_run` hint rule). It inspects at most 16 candidates, so 16 newer stale hints
report `{run: null}` (the dot then opens Repeat run). Like the other reads it runs inside
the fenced history snapshot: a restore or epoch change is 409 `history-changed`, a
storage failure 503 `storage-unavailable`.
Run and output reads accept the returned `epoch`; current-file routes require
the returned epoch, current scope and occurrence coordinates. Run integers are
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
@@ -582,9 +1390,11 @@ 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) remains outside #209; every prompt block now renders the concept's
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
gap is closed, and only the production-only surfaces above keep their own rules
(D48). 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.
+24 -11
View File
@@ -4,9 +4,17 @@ import os
import sqlite3
try:
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
from ..utils.inspection_response import (
MAX_LIBRARY_BYTES,
_library_projection,
library_metadata,
)
except ImportError:
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
from utils.inspection_response import (
MAX_LIBRARY_BYTES,
_library_projection,
library_metadata,
)
def _display_bytes(value: bytes | None) -> str | None:
@@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
return saved, _library_projection({"status": "limited"})
pair = pairs[0] if pairs else None
title = pair["title"] if pair is not None else None
category = pair["category"] if pair is not None else row["category"]
tags, limited = _tags(
conn,
pair["id"] if pair is not None else row["prompt_id"],
pair=pair is not None,
recipe_tags, limited = (
_tags(conn, pair["id"], pair=True) if pair is not None else ([], False)
)
positive_tags = []
if not recipe_tags and not limited:
# D39: only read the positive's tags when they are what the panel shows.
positive_tags, limited = _tags(conn, row["prompt_id"], pair=False)
metadata = library_metadata(
row["category"],
positive_tags,
(pair["category"], recipe_tags) if pair is not None else None,
)
limited = limited or any(
value is not None and len(value) > MAX_LIBRARY_BYTES
for value in (title, category)
for value in (title, metadata["category"])
)
if pair is not None:
saved["pair"] = {
@@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
"positive_id": row["prompt_id"],
"pair_id": pair["id"] if pair is not None else None,
"title": _display_bytes(title),
"category": _display_bytes(category),
"tags": tags,
"metadata_scope": "pair" if pair is not None else "positive",
**metadata,
"category": _display_bytes(metadata["category"]),
}
)
if limited:
+59 -1
View File
@@ -10,7 +10,7 @@ import re
import sqlite3
from typing import Iterator
from .execution_contexts import content_digest
from .execution_contexts import _hints, _values, content_digest
from .execution_history import HistoryEpochChanged
from .execution_outputs import _capture, _REFERENCE_COLUMNS
from .library import _ELIGIBLE
@@ -98,6 +98,8 @@ _ORDER = (
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
)
# Stale-hint rows a newest-pair lookup inspects before it reports none.
_PAIR_RUN_CANDIDATES = 16
_MATCHING = (
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
@@ -410,6 +412,62 @@ class HistoryReadOperations:
result["context_summaries"] = list(ordered.values())
return result
def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None:
"""The newest matching run whose current saved-pair hint names `pair_id`.
None when the positive is not an eligible saved prompt or the pair is
not one of its pairs; ``{"run": None}`` when no recorded run names it.
Only a hint captured in this epoch and still resolving to the same pair
counts, as in ``get_history_run``.
"""
conn = self._history_connection()
saved = conn.execute(
"SELECT CASE WHEN typeof(p.text)='text' AND "
"length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text "
f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
(positive_id,),
).fetchone()
if saved is None or (
conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
(pair_id, positive_id),
).fetchone()
is None
):
return None
if saved["text"] is None:
return {"run": None}
epoch = self.get_execution_epoch()
candidates = conn.execute(
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
"c.positive_snapshot_id,c.negative_snapshot_id "
"FROM execution_runs r JOIN execution_run_contexts rc "
"ON rc.run_id=r.run_id JOIN execution_contexts c "
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
"ON s.snapshot_id=c.positive_snapshot_id "
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
(
content_digest("text", (saved["text"],)),
pair_id,
epoch,
_PAIR_RUN_CANDIDATES,
),
).fetchall()
for row in candidates:
positive = _values(conn, row["positive_snapshot_id"])
negative = _values(conn, row["negative_snapshot_id"])
if positive is None or positive["raw"] != saved["text"]:
continue
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
if hints[2] == pair_id:
return {
"run": {
key: row[key] for key in ("run_id", "anchor_id", "context_id")
}
}
return {"run": None}
def get_history_run(self, run_id: str) -> dict | None:
conn = self._history_connection()
run = self.get_execution_run(run_id)
+152
View File
@@ -0,0 +1,152 @@
"""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
def get_root_favorites(self, root_key: str) -> set[str]:
"""Every relative key favorited under *root_key* (a primary-key range).
The Favorites listing (``favorites=1``) intersects these with the
root's scan, so its size follows the root's favorites, not its files.
"""
conn = self.model.get_connection()
rows = conn.execute(
"SELECT relative_path FROM image_favorites WHERE root_key=?",
(root_key,),
)
return {path for (path,) in rows}
+91 -8
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,
}
@@ -307,7 +388,7 @@ class LibraryOperations:
for row in conn.execute(
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
"pp.category,pp.title,pp.archived,"
"pp.negative_prompt_id IS NULL AS explicit_empty,"
"pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count "
"FROM prompt_pairs pp "
@@ -323,6 +404,8 @@ class LibraryOperations:
row["tags"] = []
row["archived"] = bool(row["archived"])
row["explicit_empty"] = bool(row["explicit_empty"])
# The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset.
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
if by_id:
marks = ",".join("?" for _ in by_id)
for row in conn.execute(
+18
View File
@@ -16,6 +16,8 @@ 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 .saved_views import migrate_saved_views, saved_views_schema_error
from .execution_history_schema import (
history_schema_error,
migrate_execution_history,
@@ -298,6 +300,8 @@ class PromptModel:
self._migrate_prompt_pairs(conn)
migrate_execution_history(conn)
migrate_library_state(conn)
migrate_image_favorites(conn)
migrate_saved_views(conn)
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
conn.execute("SAVEPOINT prompt_pair_schema")
@@ -1016,6 +1020,10 @@ class PromptModel:
Args:
path: File to inspect
require_library_state: Also require the tables every current
database has (Library state, image favorites and saved
views), 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 +1078,16 @@ 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
views_error = saved_views_schema_error(
conn, allow_missing=not require_library_state
)
if views_error:
return False, views_error
return True, "ok"
except sqlite3.Error as e:
return False, f"Invalid SQLite database: {e}"
+217 -2
View File
@@ -6,11 +6,13 @@ 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 .saved_views import SavedViewOperations
from .library_commands import LibraryCommandOperations
from .vocabulary import VocabularyOperations
from .execution_history import ExecutionHistoryOperations
@@ -179,6 +181,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 +306,8 @@ class PromptDatabase(
ExecutionContextOperations,
ExecutionOutputOperations,
HistoryReadOperations,
ImageFavoriteOperations,
SavedViewOperations,
):
"""Database operations class for managing prompts."""
@@ -2160,6 +2280,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:
+334
View File
@@ -0,0 +1,334 @@
"""Persisted saved views (D16): a named Images or Library toolbar state.
A view's state holds exactly its page's toolbar values and its Refine-this-view
refinements, whitelisted per page and stored as compact JSON of at most 4 KB.
Writes (the API's POST and PATCH) validate strictly. Startup and restore check
only the structure (the table, its columns and each row's types, sizes and JSON
object), never the value rules: a row saved under older rules must not stop the
database from opening. Startup is more tolerant still: a row whose state is not
a JSON object is logged by id, left in place and skipped by every read, while
restore rejects such a backup. Reads normalize instead, so a stale row opens with its
usable values and defaults for the rest. Optional in old backups, required once
a database is current (see ``library_schema.py``).
"""
import json
import logging
import re
import sqlite3
from typing import Optional
# The models logger's parent ("prompt_manager.database.models" logs there too).
_LOGGER = logging.getLogger("prompt_manager.database.saved_views")
TABLE = "saved_views"
PAGES = ("images", "library")
# The schema check's literal SQL repeats the name and state caps (names in
# bytes, at most 4 per character); keep them in step.
MAX_NAME_LENGTH = 100
MAX_STATE_BYTES = 4096
MAX_TAGS = 20
MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
FALLBACK_NAME = "Saved view"
class SavedViewInvalid(ValueError):
"""A request's page, name or state breaks the write rules (a 400)."""
# (kind, limit-or-choices, default) per state key; "text" values are
# length-capped. The defaults match the client's (saved-views.mjs FIELDS).
_STATE_FIELDS = {
"images": {
"q": ("text", 500, ""),
"category": ("text", 100, ""),
"sort": ("choice", _IMAGE_SORTS, "newest"),
"source": ("source", None, "all"),
"folder": ("text", 1024, ""),
"media": ("choice", _MEDIA, "all"),
"tags": ("tags", None, ()),
# The Favorites page's filter (#249 D51): a view saved there reopens it.
"favorites": ("bool", None, False),
},
"library": {
"q": ("text", 500, ""),
"category": ("text", 100, ""),
"sort": ("choice", _LIBRARY_SORTS, "newest"),
"tags": ("tags", None, ()),
},
}
_DDL = (
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"page TEXT NOT NULL CHECK(page IN ('images','library')), "
"name TEXT NOT NULL, state TEXT NOT NULL, position INTEGER NOT NULL, "
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
)
_COLUMNS = (
("id", "INTEGER", 0, 1),
("page", "TEXT", 1, 0),
("name", "TEXT", 1, 0),
("state", "TEXT", 1, 0),
("position", "INTEGER", 1, 0),
("created_at", "TEXT", 1, 0),
("updated_at", "TEXT", 1, 0),
)
_SELECT = "SELECT id, page, name, state FROM saved_views"
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql).lower()
def validate_name(name: object) -> str:
"""The trimmed name, 1 to 100 characters without control characters."""
if not isinstance(name, str):
raise SavedViewInvalid("Name must be a string")
trimmed = name.strip()
if not 1 <= len(trimmed) <= MAX_NAME_LENGTH:
raise SavedViewInvalid(f"Name must be 1 to {MAX_NAME_LENGTH} characters")
if _CONTROL.search(trimmed):
raise SavedViewInvalid("Name contains control characters")
return trimmed
def _validate_value(key: str, kind: str, limit, value: object) -> None:
"""Raise ``SavedViewInvalid`` when *value* breaks *key*'s write rule."""
if kind == "tags":
if not isinstance(value, list) or len(value) > MAX_TAGS:
raise SavedViewInvalid(
f"State '{key}' must be a list of at most {MAX_TAGS}"
)
for tag in value:
if (
not isinstance(tag, str)
or not tag.strip()
or len(tag) > MAX_TAG_LENGTH
or "," in tag
or _CONTROL.search(tag)
):
raise SavedViewInvalid(f"State '{key}' holds an invalid tag")
return
if kind == "bool":
if not isinstance(value, bool):
raise SavedViewInvalid(f"State '{key}' must be true or false")
return
if not isinstance(value, str):
raise SavedViewInvalid(f"State '{key}' must be a string")
if kind == "choice" and value not in limit:
raise SavedViewInvalid(f"State '{key}' is not a supported value")
if kind == "source" and not _SOURCE.fullmatch(value):
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
def encode_state(page: object, state: object) -> str:
"""Validate *state* for *page* and return its compact JSON (at most 4 KB)."""
if page not in PAGES:
raise SavedViewInvalid("Page must be 'images' or 'library'")
fields = _STATE_FIELDS[page]
if not isinstance(state, dict):
raise SavedViewInvalid("State must be an object")
if set(state) != set(fields):
raise SavedViewInvalid(
f"State for {page} must contain exactly " + ", ".join(sorted(fields))
)
for key, (kind, limit, _default) in fields.items():
_validate_value(key, kind, limit, state[key])
encoded = json.dumps(
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
)
if len(encoded.encode("utf-8")) > MAX_STATE_BYTES:
raise SavedViewInvalid("State must encode to at most 4 KB")
return encoded
def _decode_object(raw: object) -> Optional[dict]:
"""The JSON object stored in *raw*, or None when it holds no object."""
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return None
try:
decoded = json.loads(raw)
except (ValueError, RecursionError):
return None
return decoded if isinstance(decoded, dict) else None
def normalize_state(page: str, raw: object) -> dict:
"""The page's full state from a stored row, never raising.
Every key of the page's current whitelist is present: a stored value that
passes today's write rule is kept, anything else (missing, invalid, or a
row that is not a JSON object) takes the key's default, and keys the
whitelist no longer has are dropped. An unknown page has no keys.
"""
fields = _STATE_FIELDS.get(page, {})
stored = _decode_object(raw) or {}
state = {}
for key, (kind, limit, default) in fields.items():
value = stored.get(key, default)
try:
_validate_value(key, kind, limit, value)
except SavedViewInvalid:
value = default
state[key] = list(value) if kind == "tags" else value
return state
def normalize_name(name: object) -> str:
"""A stored name made displayable: trimmed, capped, never empty."""
text = _CONTROL.sub("", name) if isinstance(name, str) else ""
trimmed = text.strip()[:MAX_NAME_LENGTH].strip()
return trimmed or FALLBACK_NAME
def saved_views_schema_error(
conn: sqlite3.Connection,
*,
allow_missing: bool = True,
check_states: bool = True,
) -> Optional[str]:
"""Reject unsupported saved-views schemas and structurally invalid rows.
Reads only. Checks the table, its columns, each row's types and sizes and
that each state is a JSON object; never the value rules, which change
between versions and are normalized on read instead. A missing table is
acceptable for backups that predate it unless ``allow_missing`` is false.
``check_states=False`` (live startup) skips the JSON-object check; such
rows are reported by ``unusable_view_ids`` and skipped on read.
"""
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 saved views schema"
tables = [sql for kind, _name, sql in objects if kind == "table"]
if len(tables) != 1 or len(objects) != 1:
return "Saved views name is reserved by an unsupported schema object"
if _compact(tables[0] or "") != _compact(_DDL):
return "Saved views 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 "Saved views has invalid columns"
# Literal SQL: the table name and limits are constants of this module.
if conn.execute(
"SELECT 1 FROM saved_views WHERE typeof(page) != 'text' "
"OR page NOT IN ('images','library') "
"OR typeof(name) != 'text' OR typeof(state) != 'text' "
"OR typeof(position) != 'integer' OR typeof(created_at) != 'text' "
"OR typeof(updated_at) != 'text' "
"OR length(CAST(name AS BLOB)) > 400 "
"OR length(CAST(state AS BLOB)) > 4096 "
"OR length(CAST(created_at AS BLOB)) > 64 "
"OR length(CAST(updated_at AS BLOB)) > 64 LIMIT 1"
).fetchone():
return "Saved views contains invalid values"
if check_states and unusable_view_ids(conn):
return "Saved views contains invalid values"
return None
def unusable_view_ids(conn: sqlite3.Connection) -> list[int]:
"""Ids of rows whose state is not a JSON object (never listed)."""
return [
view_id
for view_id, state in conn.execute("SELECT id, state FROM saved_views")
if _decode_object(state) is None
]
def migrate_saved_views(conn: sqlite3.Connection) -> None:
"""Install the table atomically; validate before and after creating it."""
conn.execute("SAVEPOINT saved_views_schema")
try:
error = saved_views_schema_error(conn, check_states=False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
error = saved_views_schema_error(conn, allow_missing=False, check_states=False)
if error:
raise sqlite3.DatabaseError(error)
# A corrupt state never stops the live database from opening: the row
# stays (no data is destroyed) and reads skip it. Only its id is logged.
for view_id in unusable_view_ids(conn):
_LOGGER.warning(
"Saved view id %d has an unreadable state and is hidden", view_id
)
conn.execute("RELEASE saved_views_schema")
except BaseException:
conn.execute("ROLLBACK TO saved_views_schema")
conn.execute("RELEASE saved_views_schema")
raise
def _view(row) -> dict:
view_id, page, name, state = row
return {
"id": view_id,
"page": page,
"name": normalize_name(name),
"state": normalize_state(page, state),
}
class SavedViewOperations:
"""PromptDatabase mixin: list, create, rename and delete saved views."""
def list_saved_views(self) -> list[dict]:
"""Every view, in list order (position, then creation)."""
rows = self.model.get_connection().execute(_SELECT + " ORDER BY position, id")
# A row whose state is no JSON object (logged at startup) is never listed.
return [_view(row) for row in rows if _decode_object(row[3]) is not None]
def create_saved_view(self, page: object, name: object, state: object) -> dict:
"""Validate and append one view at the end of the list."""
from .models import utc_now_iso # models imports this module's schema
encoded = encode_state(page, state)
trimmed = validate_name(name)
now = utc_now_iso()
with self.model.get_connection() as conn:
cursor = conn.execute(
"INSERT INTO saved_views (page, name, state, position, "
"created_at, updated_at) SELECT ?, ?, ?, "
"COALESCE(MAX(position), -1) + 1, ?, ? FROM saved_views",
(page, trimmed, encoded, now, now),
)
row = conn.execute(
_SELECT + " WHERE id = ?", (cursor.lastrowid,)
).fetchone()
return _view(row)
def rename_saved_view(self, view_id: int, name: object) -> Optional[dict]:
"""The renamed view, or None when no view has *view_id*."""
from .models import utc_now_iso
trimmed = validate_name(name)
with self.model.get_connection() as conn:
conn.execute(
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ?",
(trimmed, utc_now_iso(), view_id),
)
row = conn.execute(_SELECT + " WHERE id = ?", (view_id,)).fetchone()
# A hidden (unreadable) view is not found, as in the listing.
return _view(row) if row and _decode_object(row[3]) is not None else None
def delete_saved_view(self, view_id: int) -> bool:
"""True when a view was removed."""
with self.model.get_connection() as conn:
cursor = conn.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
return cursor.rowcount > 0
+27 -1
View File
@@ -25,6 +25,7 @@ from .prompts import PromptRoutesMixin
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .vocabulary import VocabularyRoutesMixin
from .saved_views import SavedViewRoutesMixin
from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
@@ -248,6 +249,7 @@ class PromptManagerAPI(
PairRoutesMixin,
LibraryRoutesMixin,
VocabularyRoutesMixin,
SavedViewRoutesMixin,
HistoryRoutesMixin,
PromptRoutesMixin,
ImageRoutesMixin,
@@ -283,6 +285,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 +360,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.
@@ -409,6 +434,7 @@ class PromptManagerAPI(
self._register_pair_routes(routes)
self._register_library_routes(routes)
self._register_vocabulary_routes(routes)
self._register_saved_view_routes(routes)
self._register_history_routes(routes)
self._register_prompt_routes(routes)
self._register_image_routes(routes)
+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)
+37
View File
@@ -388,6 +388,9 @@ class HistoryRoutesMixin:
routes.get("/prompt_manager/library/{positive_id}/history")(
self.get_library_history
)
routes.get(
"/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest"
)(self.get_newest_pair_run)
routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run)
routes.get("/prompt_manager/history/runs/{run_id}/outputs")(
self.get_history_outputs
@@ -473,6 +476,40 @@ class HistoryRoutesMixin:
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_newest_pair_run(self, request: web.Request) -> web.Response:
"""The pair's newest recorded run for the history run dot (#249 D26)."""
_query(request)
if request.query:
raise ValueError("Invalid newest pair run query")
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
pair_id = _decimal(request.match_info["pair_id"], minimum=1)
fence = self._history_fence()
db = self.db
def project() -> bytes:
generation = fence.begin()
try:
with db.recorded_history_snapshot() as epoch:
result = db.get_newest_pair_run(positive_id, pair_id)
finally:
db.model.close()
if result is None:
fence.validate(epoch, generation)
raise HistoryReadError(404, "not-found")
encoded = _serialize_history(
{
"success": True,
"history_version": 1,
"history_epoch": epoch,
**result,
}
)
fence.validate(epoch, generation)
return encoded
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_history_run(self, request: web.Request) -> web.Response:
_query(request)
+955 -143
View File
File diff suppressed because it is too large Load Diff
+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
+115
View File
@@ -0,0 +1,115 @@
"""Saved views (D16): list, create, rename and delete named toolbar states.
The page, the name and the whitelisted per-page state are validated here
(through ``database/saved_views.py``) before anything is written. Only that
request validation (``SavedViewInvalid``) is a 400 carrying its message; any
other failure, including a ``ValueError`` raised by the database layer, is a
logged 500 with a generic message.
"""
from functools import wraps
from typing import Callable
from aiohttp import web
try:
from ...database.saved_views import SavedViewInvalid, encode_state, validate_name
except ImportError:
from database.saved_views import SavedViewInvalid, encode_state, validate_name
_CREATE_FIELDS = frozenset({"page", "name", "state"})
_RENAME_FIELDS = frozenset({"name"})
_MAX_ID = 2**63 - 1
class SavedViewNotFound(LookupError):
pass
def saved_view_response(handler: Callable) -> Callable:
"""``{success:false,error}`` with 400, 404 or 500 on every failure."""
@wraps(handler)
async def wrapped(self, request: web.Request) -> web.Response:
try:
return await handler(self, request)
except SavedViewNotFound:
return web.json_response(
{"success": False, "error": "Saved view not found"}, status=404
)
except SavedViewInvalid as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception:
self.logger.exception("Saved view operation failed")
return web.json_response(
{"success": False, "error": "Saved view operation failed"}, status=500
)
return wrapped
async def _body(request: web.Request, fields: frozenset) -> dict:
"""A JSON object whose keys are exactly ``fields``."""
try:
body = await request.json()
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
raise SavedViewInvalid("Expected a JSON object") from None
if not isinstance(body, dict):
raise SavedViewInvalid("Expected a JSON object")
if set(body) != fields:
raise SavedViewInvalid(
"Request must contain exactly " + ", ".join(sorted(fields))
)
return body
def _view_id(request: web.Request) -> int:
raw = request.match_info["view_id"]
if not raw.isascii() or not raw.isdigit():
raise SavedViewInvalid("Invalid saved view ID")
value = int(raw)
if not 1 <= value <= _MAX_ID:
raise SavedViewInvalid("Invalid saved view ID")
return value
class SavedViewRoutesMixin:
def _register_saved_view_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/saved-views")(self.list_saved_views)
routes.post("/prompt_manager/saved-views")(self.create_saved_view)
routes.patch("/prompt_manager/saved-views/{view_id}")(self.rename_saved_view)
routes.delete("/prompt_manager/saved-views/{view_id}")(self.delete_saved_view)
@saved_view_response
async def list_saved_views(self, request: web.Request) -> web.Response:
views = await self._run_in_executor(self.db.list_saved_views)
return web.json_response({"success": True, "views": views})
@saved_view_response
async def create_saved_view(self, request: web.Request) -> web.Response:
body = await _body(request, _CREATE_FIELDS)
encode_state(body["page"], body["state"])
validate_name(body["name"])
view = await self._run_in_executor(
self.db.create_saved_view, body["page"], body["name"], body["state"]
)
return web.json_response({"success": True, "view": view}, status=201)
@saved_view_response
async def rename_saved_view(self, request: web.Request) -> web.Response:
view_id = _view_id(request)
body = await _body(request, _RENAME_FIELDS)
validate_name(body["name"])
view = await self._run_in_executor(
self.db.rename_saved_view, view_id, body["name"]
)
if view is None:
raise SavedViewNotFound
return web.json_response({"success": True, "view": view})
@saved_view_response
async def delete_saved_view(self, request: web.Request) -> web.Response:
view_id = _view_id(request)
if not await self._run_in_executor(self.db.delete_saved_view, view_id):
raise SavedViewNotFound
return web.json_response({"success": True, "id": view_id})
+640
View File
@@ -0,0 +1,640 @@
// 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');
/**
* The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
* hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
* after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
* from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
*/
export function reflow(row,items) {
const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
// The unrounded boxes (`exact`); rounding happens once, on the result.
const rowWidth=row.exact?.width??row.width;
const exact=item=>item.exact??{left:item.left,width:item.width};
const grows=item=>parseFloat(item.flexGrow)>0;
const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
const lines=[];
const order=row.items.filter(entry=>entry.shown);
for(const item of items.filter(entry=>entry.shown)) {
const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
// The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
}
const placed=new Map();let top=0;
const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height;
const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
for(const line of lines) {
const free=freeOf(line);
const growing=line.filter(grows);
const height=Math.max(...line.map(item=>item.height));
let x=0,previous=null;
for(const item of line) {
// A growing item keeps its concept width plus the space its line gained.
const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
// An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
// moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
const before=previous&&placed.get(previous);
const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
x=left+width+gap;previous=item;
}
top+=height+rowGap;
}
const moved=item=>{
const box=placed.get(item);
if(!box)return item;
const grown=box.width-exact(item).width;
return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
};
return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
}
/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
export function withoutItems(row,isRemoved) {
return reflow(row,row.items.filter(item=>!isRemoved(item)));
}
/** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */
export async function seedConcept(request,data={}) {
const response=await request.post('/__test__/concept/reset',{data});
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);
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
}
/** 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` (#249 E3) holds the Prompt library's toolbar,
* caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary,
* negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading.
*/
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)};
}
// ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps
// them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"),
// so every Library box is found by descendant and placed relative to `main` or its own card.
// A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point).
const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]);
function libraryCardOf(card,origin) {
const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer');
const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags');
const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading');
const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions');
const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight));
const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)});
return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'),
children:[...card.children].map(classes),
art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'),
items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art),
...css(node,'objectFit')}))})),
content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})),
title:when(title,()=>({...layout(title),...edges(title),...relative(title,content),
items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})),
section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section),
label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})),
actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section),
buttons:[...group.children].map(node=>controlOf(node,group))})),
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})),
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})),
// A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it).
tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width},
items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})),
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card),
// What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out).
facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
}
function libraryOf() {
const grid=main?.querySelector('.library-grid');
if(!grid)return null;
const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption');
const cards=[...grid.querySelectorAll('.prompt-card')];
// Whatever the page shows after the grid, in document order (the concept: nothing).
const trailing=[];
for(let node=grid;node&&node!==main;node=node.parentElement)
for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()});
const style=getComputedStyle(grid);
return {toolbar:when(toolbar,rowOf),
caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main),
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span),
top:relative(span,caption).top,height:relative(span,caption).height}))})),
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
}
// ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds
// the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so
// each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section.
const spoken=node=>node.classList.contains('ca-visually-hidden');
const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset');
const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height',
'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));};
// What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`).
const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`),
spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))});
function promptSectionOf(section,origin) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
return {className:classes(section),...relative(section,origin),...edges(section),
label:when(label,()=>({text:label.textContent,...font(label)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})),
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),
lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function panelHeadingOf(heading,origin) {
const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions');
return {...layout(heading),...edges(heading),...relative(heading,origin),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})),
note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))};
}
function laneOf(lane,timeline) {
const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot');
return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'),
before:pseudo(lane,'::before'),
dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})),
label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})),
run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null};
}
function timelineOf(timeline,origin) {
const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span');
return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin),
positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'),
span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})),
lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)),
structure:structureOf(timeline)};
}
function variantsOf(panel) {
const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower');
return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel),
heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)),
lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel),
items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))};
}
function compareOf(section) {
const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid');
return {...edges(section),...relative(section,main),structure:structureOf(section),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})),
intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})),
grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section),
items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid),
sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})),
notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})),
added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))};
}
function historyOf() {
const panel=main?.querySelector('section.timeline-panel');
if(!panel)return null;
const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery');
const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown);
const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta');
const galleryHeading=gallery?.querySelector(':scope > .panel-heading');
return {
summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary),
chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})),
sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)),
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary),
items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})),
variants:variantsOf(panel),
compare:when(compare,compareOf),
gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main),
heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})),
};
}
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:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
/**
* The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
* a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
* when absent. Both are measured by one in-page function with the concept's selectors in either document.
*/
export async function inspectorOf(page) {
return {panel:await page.evaluate(measureInspector,'#image-inspector'),
viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
}
/** Runs in the page; self-contained. Every box is relative to the measured root. */
export function measureInspector(selector) {
const root=document.querySelector(selector);
if(!root)return null;
const px=value=>Math.round(value);
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
const rect=node=>node.getBoundingClientRect();
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
const classes=node=>[...node.classList].sort().join(' ');
const when=(node,measure)=>node?measure(node):null;
// Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
return copy.textContent.replace(/\s+/g,' ').trim();};
const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
function iconOf(node) {
const svg=node.querySelector(':scope > svg.icon');
if(!svg)return null;
return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
}
function controlOf(node) {
const style=getComputedStyle(node);
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
}
function chipOf(node) {
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
minHeight:getComputedStyle(node).minHeight};
}
function rowOf(row) {
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
}
function sectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
return {className:classes(section),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
}
const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
const panel=root.classList.contains('detail-panel');
// The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
// `display: contents` is transparent: its sections count as the wrapper's own children.
const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
const sections=parts.filter(item=>item.classList.contains('prompt-section'));
const firstSection=parts.indexOf(sections[0]);
const rows=parts.filter(item=>item.classList.contains('tags'));
const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
const filename=parts.find(item=>item.matches('p.small'));
const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
// What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
return {
className:classes(root),label:root.getAttribute('aria-label'),
...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
inner:when(inner,()=>({...edges(inner),...box(inner)})),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
// The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
};
}
+449 -129
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));});
@@ -71,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', category_scope: 'pair', tags_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
expect(gallery.images[0].root_scope).toBe(complete.root_scope);
@@ -85,8 +89,10 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
await expect(await request.post('/__test__/reset')).toBeOK();
});
const inspector=page=>page.locator('.image-details');
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
const inspector=page=>page.locator('.detail-panel');
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
async function fullFixture(page,request) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
@@ -95,47 +101,257 @@ async function downloadText(page,action) {
const [download]=await Promise.all([page.waitForEvent('download'),action.click()]);
const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')};
}
/** The viewer's full details for one file (D19: the card image opens the viewer; D20: only it shows provenance and raw). */
async function viewerDetails(page,name,root=0) {
await page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();return details;
}
/** Rewrites the next inspection reads; `null` passes them through. */
async function patchInspection(page) {
const state={patch:null};
await page.route('**/images/inspect/**',async route=>{
if (!state.patch) return route.continue();
const response=await route.fetch();const data=await response.json();state.patch(data);await route.fulfill({response,json:data});
});
return state;
}
/** The side panel's concept structure (app.js imageDetails a400–414): element, class and visible text, in order. */
const panelStructure=panel=>panel.evaluate(node=>{
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
const inner=node.querySelector(':scope > .detail-inner');
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
});
test('panel matches the concept order and copy',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
await expect(panel).toHaveAttribute('id','image-inspector');await expect(panel).toHaveClass('detail-panel file-inspector');
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
const badges=panel.locator('.detail-inner > .tags').first();
await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
await expect(panel.locator('span.chip.blue')).toHaveText('Execution recorded');
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
// Unlinked: no badge, category, tags, pair or history.
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.detail-inner > .tags').last();
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
await selectFile(page,'malformed.png');
await expect(panel.locator('.detail-inner > p.small[role="alert"]')).toHaveText('Could not read prompts from this file.');
await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']);
});
test('tag chip filters Images by that tag',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await expect(await request.post('/prompt_manager/prompts/tags',{data:{prompt_id:id,tags:['geometric']}})).toBeOK();
// workflow-only.png is linked to the positive alone, so its chips are the positive's tags, which the filter matches.
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/empty.png"]')).toHaveCount(0);
// #249 D44: the chip's Tags filter shows on the toolbar's Filters button.
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('geometric');
});
test('pair send and history appear only with real roles and link',async({page,request})=>{
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
await panel.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'explicit-empty.png');
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
});
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
await expect(panel.locator('details,summary,pre,a')).toHaveCount(0);
await expect(panel.getByRole('button',{name:/Download|Open full preview/})).toHaveCount(0);
await expect(panel).not.toContainText(/File & provenance|Workflow & raw metadata|Download/);
// The viewer keeps them until its own full details (D3).
const details=await viewerDetails(page,'complete.png');
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await details.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(details.getByRole('button',{name:'Download metadata JSON',exact:true})).toBeVisible();
});
/** The viewer's full details (app.js imageDetails(i, true)): its children, and the class-less wrapper's in order. */
const viewerStructure=details=>details.evaluate(node=>{
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
const inner=node.querySelector(':scope > div');
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
});
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
// The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20).
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']});
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
const provenance=details.locator('details').first();await provenance.locator('summary').click();
await expect(provenance.locator('p.small').first()).toHaveText('Current file at this source; historical bytes are not verified.');
await expect(provenance.locator('dl.meta-grid dt')).toHaveText(['Source','Path','Size','Dimensions','Format','Media type','Modified']);
// D38: the Path value stays text, with "Show in folder" right after it.
await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png Show in folder');
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
for (const name of ['Download metadata JSON','Download workflow JSON']) await expect(raw.getByRole('button',{name,exact:true})).toBeVisible();
// D21 wording for execution and embedded parameters; an unverified format names its extension without a dot.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await page.keyboard.press('Escape');details=await viewerDetails(page,'explicit-empty.png');await details.getByText('File & provenance',{exact:true}).click();
await expect(details.getByText('Prompt evidence: execution recorded',{exact:true})).toBeVisible();
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
await page.keyboard.press('Escape');details=await viewerDetails(page,'ambiguous.png');await details.getByText('File & provenance',{exact:true}).click();
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
// No badge, no line; no resolved pair or link, no buttons after the details.
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
expect((await viewerStructure(details)).outer).toEqual(['div']);
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
// The history button leaves the viewer for the prompt's history.
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
await details.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
});
// D38 Show in folder: a listed file's Path offers it; the stubbed opener records what the route resolved.
test('Show in folder asks the server to open the listed file and announces each outcome',async({page,request})=>{
await fullFixture(page,request);
await expect(inspector(page).getByRole('button',{name:/in folder$/})).toHaveCount(0);
const details=await viewerDetails(page,'complete.png');
const provenance=details.locator('details').first();await provenance.locator('summary').click();
const path=provenance.locator('dl.meta-grid > div').nth(1);await expect(path.locator('dt')).toHaveText('Path');
const show=path.locator('dd > button');
await expect(show).toHaveText('Show in folder');await expect(show).toHaveAccessibleName('Show complete.png in folder');
const toast=page.locator('#workspace-toast');
const [sent]=await Promise.all([page.waitForRequest(entry=>entry.url().endsWith('/prompt_manager/images/reveal')),show.click()]);
expect(sent.method()).toBe('POST');
// The route takes only a same-origin JSON request, as a real browser sends it.
const sentHeaders=await sent.allHeaders();
expect(sentHeaders.origin).toBe(new URL(page.url()).origin);expect(sentHeaders['content-type']).toBe('application/json');
const body=sent.postDataJSON();
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
await expect(toast).toHaveText('Opened the folder on this computer.');
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
expect(await opened()).toEqual(['output/inspector/complete.png']);
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
await show.click();await expect(toast).toHaveText(message);
}
// A reveal already in progress answers 429 busy.
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
await show.click();await expect(toast).toHaveText('The folder is already opening.');
await page.unroute('**/prompt_manager/images/reveal');
// A proxied request counts as remote, so the real route answers 403 not-local.
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
expect(await opened()).toEqual(['output/inspector/complete.png']);
});
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
await expect(panel.getByRole('heading',{name:'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.locator('.file-evidence')).toHaveText('Workflow prompt evidence');
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
expect(await panel.locator('.detail-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26);
expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30);
// The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock.
const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
}
await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category');
await expect(panel.locator('.file-vocabulary button')).toHaveCount(0);
await panel.getByText('File & provenance',{exact:true}).click();
await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file');
await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/);
await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible();
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1);
// Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines.
const details=await viewerDetails(page,'complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');await expect(details).toContainText('Recipe category: Inspection');
await details.getByText('File & provenance',{exact:true}).click();
await expect(details).toContainText('Current file at this source; historical bytes are not verified.');await expect(details).toContainText('Saved association; not verified against current file');
await expect(details.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(details).not.toContainText(/Configuration evidence|Embedded API configuration|Saved workflow configuration/);
await expect(details.getByRole('button',{name:'Prompt history',exact:true})).toBeVisible();
if (testInfo.project.name === 'chromium') {
await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'});
}
await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1);
await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click();
await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible();
if (testInfo.project.name === 'chromium') {
await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'});
await details.getByText('Prompt evidence: workflow inferred',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('prompt-evidence.png');await page.screenshot({path});await testInfo.attach('prompt-evidence',{path,contentType:'image/png'});
}
await expect(details).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
});
test('full inspector preserves long role text and exact sends',async({page,request,context})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});});
try {
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
@@ -144,14 +360,14 @@ test('full inspector preserves long role text and exact sends',async({page,reque
});
test('full inspector downloads actual bounded evidence',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click();
await fullFixture(page,request);let panel=await viewerDetails(page,'complete.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();
const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png');
const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json();
await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope);
const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow);
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url);
await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
await page.keyboard.press('Escape');panel=await viewerDetails(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
await page.keyboard.press('Escape');panel=await viewerDetails(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0);
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/);
});
@@ -159,9 +375,9 @@ test('full inspector downloads actual bounded evidence',async({page,request})=>{
test('full inspector labels a limited workflow record without hiding prompt facts',async({page,request})=>{
// The server marks a workflow over its 4 MiB read budget `limited`: no raw text, a limited graph.
await page.route('**/images/inspect/inspector/complete.png?*',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.record_formats.workflow='limited';delete data.metadata.raw.workflow;data.metadata.workflow_graph={status:'limited',value:null};await route.fulfill({response,json:data});});
await fullFixture(page,request);const panel=inspector(page);
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
await fullFixture(page,request);const panel=await viewerDetails(page,'complete.png');
await expect(panel.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(panel.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeVisible();
await panel.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(panel.getByText('Record exceeds inspection limits; text is unavailable',{exact:true})).toBeVisible();await expect(panel.locator('pre code')).toHaveCount(1);
const workflow=panel.getByRole('button',{name:'Download workflow JSON',exact:true});await expect(workflow).toHaveAttribute('aria-disabled','true');
@@ -173,8 +389,8 @@ test('full inspector labels a limited workflow record without hiding prompt fact
test('full inspector rejects stale shared action state',async({page,request})=>{
let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();});
await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads;
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.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 fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.detail-footer button.send')).toBeVisible();const before=reads;
await openSelected(page);const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.meta-grid dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();
stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0);
stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision');
@@ -186,15 +402,32 @@ test('full inspector keeps long literal content reachable on mobile',async({page
const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'verylongmodelpath/'.repeat(30);
let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.');
await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal);
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');}
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
await expect(panel.locator('.meta-grid dd').last()).toHaveText(literal);
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();}
await page.evaluate(()=>{document.documentElement.style.zoom='1';});
const details=await viewerDetails(page,'complete.png');await details.getByText('Workflow & raw metadata',{exact:true}).click();await expect(details.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);
const summary=details.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(details).toContainText('historical bytes are not verified');
expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0);
});
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'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('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');await expect(inspector(page).getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
const heading=page.locator('#workspace-main h1');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
await viewer.locator('#viewer-details').getByRole('button',{name:'Prompt history',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${id}$`));
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
// The side panel's Prompt history does the same.
await selectFile(page,'complete.png');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
await inspector(page).getByRole('button',{name:'Prompt history',exact:true}).click();
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
});
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
@@ -226,12 +459,12 @@ test('full inspector labels container representations and releases action URLs',
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);
await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
await selectFile(page,'control-heavy.png');
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation);
for(const value of await panel.locator('.meta-grid dd').all())await expect(value).toHaveAccessibleDescription(explanation);
await page.evaluate(async()=>{
const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
@@ -240,7 +473,7 @@ test('full inspector distinguishes missing, limited and legacy unknown generatio
window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope});
window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})});
});
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0);
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.meta-grid').first().locator('dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.meta-grid dd[aria-describedby]')).toHaveCount(0);
await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();});
});
@@ -248,81 +481,165 @@ test.describe('full inspector coarse pointer',()=>{
test.use({hasTouch:true,viewport:{width:390,height:844}});
test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{
await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page);
await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.');
const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000);
await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('Full prompt context, fine geometry and light.');
const fullText=await panel.locator('.prompt-section.positive p.prompt-text').textContent();expect(fullText.length).toBeGreaterThan(1000);
const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);}
for(const zoom of [1,2]) {
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) {
for(const target of [panel.locator('.detail-footer button.send'),panel.getByRole('button',{name:'Prompt history',exact:true})]) {
await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport();
}
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toHaveText(fullText);
}
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
for(const target of await viewer.locator('.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);
}
const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport();
// #249 D34: the viewer details' disclosure summaries and "Download original" keep 40px coarse targets as well.
const rail=viewer.locator('#viewer-details');await rail.getByText('File & provenance',{exact:true}).click();await expect(rail.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
const details=await rail.locator(':is(summary,a)').evaluateAll(nodes=>nodes.map(node=>{const {width,height}=node.getBoundingClientRect();return {name:node.textContent,width,height};}));
expect(details.map(target=>target.name)).toEqual(['File & provenance','Download original','Workflow & raw metadata']);
for(const target of details){expect(target.width,target.name).toBeGreaterThanOrEqual(40);expect(target.height,target.name).toBeGreaterThanOrEqual(40);}
});
});
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 openSelected(page);
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
}
// #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-details > div > p.small')).toHaveText(loaded[159].filename);
await expect(modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0);
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`);
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close 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');
// #249 D44: Source and Folder are active.
await page.getByRole('button',{name:'Filters · 2',exact:true}).click();
await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
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 page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
await expect(position(page)).toContainText('of 1 loaded: complete.png');
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(position(page)).toContainText('of 2 loaded:');
});
test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
@@ -333,8 +650,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,10 +691,10 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open();
},preferences);
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png');
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
}
const slideshowButton=page=>mediaDialog(page).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) {
@@ -406,7 +723,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);
});
@@ -437,30 +754,32 @@ 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');
await selectFile(page,'complete.png');await openSelected(page);await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{
// A working clipboard keeps the copy action on the toast, so no "Copy prompt" fallback covers the viewer.
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{}}}));
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
for(const action of [
()=>modal.locator('.prompt-positive pre').click(),
()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
()=>modal.locator('.prompt-section.positive p.prompt-text').click(),
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).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(),
async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(),
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
]) {
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);
}
@@ -491,13 +810,13 @@ 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')),
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
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')),
pair:node.querySelector('#viewer-details > button.send')?box(node.querySelector('#viewer-details > button.send')):null};
})});
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
else await expect(rail).toHaveCSS('overflow-y','visible');
@@ -506,23 +825,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 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 page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await selectFile(page,'complete.png');await expect(inspector(page).locator('.meta-grid dd').first()).toHaveText('9007199254740993');
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.detail-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
await capture(`inline-${label}`);
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.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 expect(rail.locator('.prompt-section.negative')).toContainText('blurred edges, watermark');await rail.locator('.meta-grid').first().scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('details .meta-grid').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
await modal.getByRole('button',{name:'Close 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 selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
const send=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await openSelected(page);
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
await selectFile(page,'control-heavy.png');await openSelected(page);
await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
await modal.getByRole('button',{name:'Close 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'});
@@ -531,14 +850,14 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
test('collection viewer preserves original download and rooted retries',async({page,request})=>{
await fullFixture(page,request);let deny=true;const originals=[];
await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();});
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page);
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
await 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 openSelected(page);const modal=mediaDialog(page);
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.');
const download=modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
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);
@@ -546,38 +865,39 @@ 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();
await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
});
test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{
await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page);
const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const send=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused();
const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
const text=modal.locator('.prompt-section.positive p.prompt-text');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before);
await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1];
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).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);
// D19: focus returns to the opening image; the panel follows the file the viewer moved to.
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('article[data-image-key="0:inspector/complete.png"] .image-open')).toBeFocused();await expect(inspector(page).locator('.detail-inner > p.small')).toHaveText(key);
await openSelected(page);await page.evaluate(()=>{location.hash='/overview';});
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
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`)});
}
@@ -593,30 +913,30 @@ 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 rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).focus();await expect(rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeInViewport();
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
}
await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';});
await rail.getByText('Workflow & raw metadata',{exact:true}).click();
const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport();
const sendPair=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await sendPair.focus();await expect(sendPair).toBeInViewport();
await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport();
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('#viewer-details').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1);
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
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},
@@ -654,7 +974,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) {
@@ -678,7 +998,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);
@@ -686,12 +1006,12 @@ test('collection viewer refits a late wrapped title without replacing current me
expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true);
await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry());
await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
await expect(modal.locator('#viewer-details > div > p.small')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed);
await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true);
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
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([]);
+26 -28
View File
@@ -10,14 +10,14 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m
export const canonicalDifferences = {
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
semantics: [
'Saved combinations are distinct from the additional chronological executions',
'Negative variants are saved combinations; Recorded runs list the chronological executions',
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
'Current-file candidates disclose unknown identity and context membership',
],
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
// exact history comparison is tests/browser/history-concept.spec.mjs.
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',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
@@ -34,29 +34,27 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
await testInfo.attach(name, { path, contentType: 'image/png' });
}
const CONCEPT_HISTORY_SELECTORS = Object.freeze({
surface: 'article.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane > span', dot: '.variant-dot[aria-pressed="true"]', role: 'article.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: 'article.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot:not([hidden])',
});
const RECORDED_HISTORY_SELECTORS = Object.freeze({
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
});
export async function historyGeometry(page, production) {
const selectors = production ? {
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
positiveLabel: '.library-positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-block',
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
heading: '.library-variants h2', intro: '.library-variants > p.muted',
footer: '.library-variant-footer', all: '.library-variants button:last-child',
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
} : {
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: '.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot',
};
// Production renders the concept's history classes (#249 PR F), so both documents use the concept's selectors;
// production adds the recorded executions (#209), which the concept does not have.
const selectors = production ? { ...CONCEPT_HISTORY_SELECTORS, ...RECORDED_HISTORY_SELECTORS } : CONCEPT_HISTORY_SELECTORS;
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
const nodes = [...document.querySelectorAll(selector)];
@@ -106,8 +104,8 @@ 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 send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
const viewer = page.locator('dialog#viewer-dialog');
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click();
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
+294
View File
@@ -0,0 +1,294 @@
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} from './concept-inventory.mjs';
// Task F5 of #249: the production Prompt history (#/library/<id>) against the immutable concept's (#prompt/alpine,
// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real
// fixture: Alpine observatory with its three saved negatives, four images linked to their pairs, four recorded runs and
// real pair run counts (2, 1, 1). Heading, summary, the negative variants panel, the comparison and the shared gallery's
// heading are compared at 1600x1100 and 390x844 in both themes and motion settings, with the page's behaviors in both
// documents. Both documents are measured with "Clean architecture" selected: it is the middle lane in either order
// (D46). The topbar and sidebar are the shell spec's, the gallery's cards the Images spec's (D47). Paired captures are
// artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const TITLE='Alpine observatory';
const SELECTED='Clean architecture';
const LANES=3;
const SHOW_ARCHIVED='Show archived (0)';
const SECTIONS=['heading','history','noPageScroll'];
/**
* Recorded history deviations only (spec §8, TESTING.md D24–D26, D35, D45–D47); everything else must equal production.
* `reference.oracle` is the concept page itself, measured once its lanes list newest first with the singular for one
* (D46, D35) and once the approved Show archived button (#235) follows + Add negative: the browser lays out what moves.
*/
export function mapHistoryConcept(reference) {
const {history}=reference.oracle;
return {...reference,history:{...history,
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
summary:history.summary,
variants:{...history.variants,
// D45: the bounded "N of M saved negative variants" count stays a visually hidden status.
structure:{...history.variants.structure,spoken:[{role:'status',text:`${LANES} of ${LANES} saved negative variants`}]}},
compare:history.compare&&{...history.compare,grid:{...history.compare.grid,items:history.compare.grid.items.map((item,index)=>({...item,
// D46: the two compared blocks are named Baseline and Selected, so no action name repeats on the page.
sections:item.sections.map(section=>({...section,actions:{...section.actions,buttons:section.actions.buttons.map(button=>({...button,
label:button.label.replace(/^(Copy|Send) negative/,`$1 ${COMPARED[index]} negative`)}))}}))}))}},
// D47: the gallery's body is the Images collection (layout row, cards, side panel), so only its place, frame and
// heading are compared here; the cards are the Images comparison's.
gallery:history.gallery&&withoutHeight(history.gallery),
}};
}
const COMPARED=['baseline','selected'];
const withoutHeight=({height,...gallery})=>gallery;
function compare(reference,actual) {
const only=inventory=>Object.fromEntries(SECTIONS.map(key=>[key,key==='history'&&inventory.history?.gallery
?{...inventory.history,gallery:withoutHeight(inventory.history.gallery)}:inventory[key]]));
return diffInventory(stripExact(only(mapHistoryConcept(reference))),stripExact(only(actual)));
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertHistoryMatch(reference,actual) {
const diff=compare(reference,actual);
if(diff.length)throw new Error(`the Prompt history differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
// Two frames, then every running transition finished.
const settle=page=>page.evaluate(async()=>{
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
new Promise(resolve=>setTimeout(resolve,2000))]);
});
const decoded=page=>page.evaluate(async()=>{
await document.fonts.ready;
await Promise.all([...document.querySelectorAll('.history-gallery img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
});
const dot=(page,name)=>page.getByRole('button',{name:`Select negative ${name}`,exact:true});
const compareButton=page=>page.getByRole('button',{name:'Compare negatives',exact:true});
const galleryCards=page=>page.locator('.history-gallery .media-card');
/** Press a variant dot and wait until the summary and the gallery follow it. */
async function selectVariant(page,name) {
await dot(page,name).click();
await expect(dot(page,name)).toHaveAttribute('aria-pressed','true');
await expect(page.locator('.history-gallery .panel-heading .small')).toHaveText(`${name} · same positive prompt`);
await expect(galleryCards(page).first()).toBeVisible();
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function setCompare(page,pressed) {
const button=compareButton(page);
if(await button.getAttribute('aria-pressed')!==String(pressed))await button.click();
await expect(compareButton(page)).toHaveAttribute('aria-pressed',String(pressed));
await page.mouse.move(0,0);await settle(page);
}
async function openConceptHistory(page,theme,motion) {
await openConcept(page,'prompt/alpine',theme,motion);
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
await selectVariant(page,SELECTED);
}
async function openProductionHistory(page,theme,motion,positiveId) {
await openProduction(page,`library/${positiveId}`,theme,motion);
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
await expect(page.locator('article.surface .card-meta > span').first()).toBeVisible();
await selectVariant(page,SELECTED);
}
// D45: the recorded production rules that lay the panel out (workspace.css): the panel heading and the legend row wrap,
// All images wraps its label, and a lane's text wraps before a shown repeat-run dot. Applied to the concept so the browser
// lays out what follows; at 1600px nothing wraps.
const D45_LAYOUT=`.timeline-panel > .panel-heading, .timeline-lower { flex-wrap: wrap; }
.variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; }
.ca-tools .timeline-lower > button { max-inline-size: 100%; white-space: normal; }`;
/**
* The concept laid out as production lists it: lanes newest first with their offsets (D46), "1 run" in the singular
* (D35), the approved Show archived button after + Add negative (#235) and D45's layout rules. Returns the undo.
*/
async function conceptAsRecorded(page) {
const restoreLayout=await mutate(page,{css:D45_LAYOUT});
await page.evaluate(label=>{
const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')];
const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]);
lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,(match,before)=>`${before}1 run`);
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
},SHOW_ARCHIVED);
await settle(page);
return ()=>page.evaluate(()=>{
const {timeline,order,texts,archived}=window.__historyOracle;
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text;
archived.remove();delete window.__historyOracle;
}).then(restoreLayout);
}
/** The concept inventory, with `oracle`: the same page laid out as recorded (`conceptAsRecorded`). */
async function conceptInventory(page) {
const reference=await inventoryOf(page,'#main');
const restore=await conceptAsRecorded(page);
const oracle=await inventoryOf(page,'#main');
await restore();await settle(page);
return {...reference,oracle};
}
const negativeText=page=>page.locator('article.surface .prompt-section.negative .prompt-text').textContent();
const galleryOf=async page=>({context:await page.locator('.history-gallery .panel-heading .small').textContent(),
cards:await galleryCards(page).locator('.image-open').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))});
/** A dialog the page opens: its accessible name, how many media cards it shows once loaded and its captions; then closed. */
async function dialogOf(page,trigger,{name,cards}) {
await trigger.click();
const dialog=page.getByRole('dialog',{name,exact:true});
await expect(dialog).toBeVisible();
if(cards)await expect(dialog.locator('.media-card')).toHaveCount(cards);
// D47: neither document's media dialog carries a collection caption ("Linked images", "N loaded · …").
const captions=await dialog.locator('.section-caption').count();
const result=cards?{name,cards:await dialog.locator('.media-card').count(),captions}:{name,captions};
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
return result;
}
/** The behaviors Task F5 lists, with identical selectors in both documents. */
async function behaviorsOf(page,{sent,runDialog}) {
await selectVariant(page,'Quality baseline');
const before={negative:await negativeText(page),gallery:await galleryOf(page)};
await selectVariant(page,SELECTED);
const after={negative:await negativeText(page),gallery:await galleryOf(page)};
const pressed=[await compareButton(page).getAttribute('aria-pressed')];
await setCompare(page,true);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
const compared=await page.locator('section.surface:not(.timeline-panel) > h2').textContent();
await setCompare(page,false);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
const all=await dialogOf(page,page.getByRole('button',{name:'All images for this positive',exact:true}),{name:'All images for this positive',cards:4});
const run=await dialogOf(page,page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}),{name:runDialog});
await page.locator('.heading-actions').getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const send=await sent(page);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('h1#page-title')).toHaveText('Prompt library');
return {variant:{before,after},pressed,compared,all,run,send,back:await page.locator('h1#page-title').textContent()};
}
/** The concept's demo sender: the roles its preview lists, with their text. */
async function conceptSent(page) {
const modal=page.locator('dialog#modal[open]');
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
return roles;
}
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
function productionSent(host) {
return async page=>{
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
const roles=await host.evaluate(()=>window.previewed.pop());
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
return roles;
};
}
async function openHost(context) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
window.previewed=[];
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
apply:roles=>({targetId:target.id,values:roles}),
}});
});
return host;
}
test.beforeEach(async({page,request},testInfo)=>{
testInfo.manifest=await seedConcept(request);
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
test.setTimeout(240_000);
await page.setViewportSize({width,height});
const positiveId=testInfo.manifest.prompts[0].positive_id;
const host=await openHost(context);
try {
const result={};
for(const motion of MOTIONS) {
const capture=motion==='no-preference';
await openConceptHistory(page,theme,motion);
const reference=await conceptInventory(page);
await setCompare(page,true);
const referenceCompared=await conceptInventory(page);
if(capture)await captureCanonicalState(page,testInfo,`concept-history-${width}-${theme}`);
const referenceBehaviors=capture?await behaviorsOf(page,{sent:conceptSent,runDialog:'Repeat run'}):null;
await openProductionHistory(page,theme,motion,positiveId);
const actual=await inventoryOf(page,'#workspace-main');
await setCompare(page,true);
const actualCompared=await inventoryOf(page,'#workspace-main');
if(capture)await captureCanonicalState(page,testInfo,`production-history-${width}-${theme}`);
const actualBehaviors=capture?await behaviorsOf(page,{sent:productionSent(host),runDialog:'Recorded run'}):null;
result[motion]={reference,actual,diff:compare(reference,actual),referenceCompared,actualCompared,
compareDiff:compare(referenceCompared,actualCompared),referenceBehaviors,actualBehaviors};
}
const path=testInfo.outputPath('history-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('history-geometry',{path,contentType:'application/json'});
for(const [motion,{diff,compareDiff,actual,referenceCompared}] of Object.entries(result)) {
// The concept itself is what this comparison assumes (style.css 709–722, 1059–1179, 1426, 1546).
const {summary,variants,compare:compared,gallery}=referenceCompared.history;
expect.soft(summary,`${motion} concept surface`).toMatchObject({paddingTop:'22px',paddingLeft:'22px',borderTopLeftRadius:'7px',borderTopWidth:'1px'});
// D24: the fixture's four recorded runs give the concept's own "4 tracked runs"; production reads them.
expect.soft(summary.meta.items.map(item=>item.text),`${motion} concept counts`).toEqual(['4 tracked runs','4 images','3 negative variants']);
expect.soft(actual.history.summary.meta.items.map(item=>item.text),`${motion} tracked runs`).toEqual(['4 tracked runs','4 images','3 negative variants']);
expect.soft(variants.timeline,`${motion} concept timeline`).toMatchObject({paddingTop:'18px',paddingLeft:'20px',borderTopLeftRadius:'6px'});
variants.timeline.lanes.forEach((lane,index)=>{
expect.soft(lane,`${motion} concept lane ${index}`).toMatchObject({offset:`${38+index*47}px`,marginLeft:`${38+index*47}px`,height:57});
expect.soft(lane.dot,`${motion} concept dot ${index}`).toMatchObject({width:'29px',height:'29px',text:'−'});
});
expect.soft(variants.timeline.lanes.map(lane=>lane.run&&{width:lane.run.width,left:lane.run.left}),`${motion} concept run dots`).toEqual([{width:'12px',left:'190px'},null,null]);
expect.soft(compared.grid,`${motion} concept compare grid`).toMatchObject({columns:width>700?2:1,columnGap:'15px'});
expect.soft(compared.added.map(chip=>chip.text),`${motion} concept added terms`).toEqual(['+ distorted architecture','+ watermark','+ text']);
expect.soft(compared.added[0],`${motion} concept diff chip`).toMatchObject({fontSize:'11px',paddingTop:'3px',paddingLeft:'7px',borderTopLeftRadius:'4px',display:'inline-block'});
expect.soft(gallery,`${motion} concept gallery`).toMatchObject({marginTop:'24px',heading:{h2:{text:'Shared gallery',fontSize:'14px'},note:{text:`${SELECTED} · same positive prompt`}}});
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
expect.soft(compareDiff,`${motion} differences with Compare negatives pressed`).toEqual([]);
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
}
const {referenceBehaviors:concept,actualBehaviors:production}=result['no-preference'];
expect.soft(production,'behaviors').toEqual({...concept,run:{...concept.run,name:'Recorded run'}});
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant
// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match.
test('history comparison detects mutations',async({page},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});
await openConceptHistory(page,'dark','no-preference');
const reference=await conceptInventory(page);
await openProductionHistory(page,'dark','no-preference',testInfo.manifest.prompts[0].positive_id);
const measure=()=>inventoryOf(page,'#workspace-main');
assertHistoryMatch(reference,await measure());
const restoreCss=await mutate(page,{css:'.ca-tools .variant-dot { width: 20px }'});
const cssMutated=await measure();
expect(()=>assertHistoryMatch(reference,cssMutated)).toThrow(/history\.variants\.timeline\.lanes\.0\.dot\.width/);
await restoreCss();await settle(page);
assertHistoryMatch(reference,await measure());
await compareButton(page).evaluate(node=>node.remove());
const domMutated=await measure();
expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/);
});
+560
View File
@@ -0,0 +1,560 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
// Task 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)}`;
}
/** 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]));
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...reference.toolbar};
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 the sample tag.
const removed={};
for(const [key,selector] of [['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);
// A dispatched press, as on the concept side: a pointer click scrolls the row into view first, and the page's
// own observer (rootMargin 250px) can append the final batch and hide the button before the click lands
// (WebKit, run 39512528039). The button must still be the enabled one the user would press.
const more=page.getByRole('button',{name:'Load more images',exact:true});
await expect(more).toBeVisible();await expect(more).toHaveAttribute('aria-disabled','false');
await more.dispatchEvent('click');
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
result[motion].loadingDiff=compareLoadingRow(result[motion].conceptLoad.loadingRow,loadingRow,loadingCount);
}
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 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.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);
});
+353
View File
@@ -0,0 +1,353 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it
// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category
// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are
// artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
const TITLE='Alpine observatory';
const FIXTURE_IMAGES=24;
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
const PANEL_INSET=40;
// D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the
// concept's coarse rule covers buttons only). The recorded rule, applied to the concept under the same emulation so the
// browser lays out what follows the taller summaries; a fine pointer never matches it.
const D34_COARSE_TARGETS=`@media (pointer: coarse) {
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; }
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; }
}`;
// What an open "File & provenance" offers beyond the concept's text-only disclosure: D38's Show in folder right after
// the Path value, then D20's Download original.
const FILE_CONTROLS=[{tag:'button',text:'Show in folder',name:'Show alpine_00001.png in folder'},{tag:'a',text:'Download original',name:'Download original'}];
/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */
function withFocusablePrompts(inventory) {
const controls=[...inventory.controls];
for(const section of inventory.sections) {
const send=section.actions.buttons.at(-1);
const index=controls.findIndex(control=>control.name===send.name);
controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'});
}
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
}
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
function withCategoryScope(inventory) {
const [badge,category,...rest]=inventory.badges.chips;
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
* production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
* what follows them, as the Images spec's removed toolbar items do.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
return {
panel:withCategoryScope(withFocusablePrompts({...panel,
// D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class.
className:`${panel.className} file-inspector`,
// D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer
// reachable; at 700px and below, where the concept unsticks it, it grows instead.
overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})),
// D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library).
viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})),
// D38 and D20: the open "File & provenance" offers Show in folder and Download original.
fileControls:[...fileControls,...FILE_CONTROLS],
};
}
function compare(reference,actual,viewport) {
return diffInventory(mapInspectorConcept(reference,{viewport}),actual);
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertInspectorMatch(reference,actual,viewport) {
const diff=compare(reference,actual,viewport);
if(diff.length)throw new Error(`the inspector differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
// Two frames, then every running transition finished.
const settle=page=>page.evaluate(async()=>{
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
new Promise(resolve=>setTimeout(resolve,2000))]);
});
const panelOf=page=>page.locator('#image-inspector');
/**
* The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production
* has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison.
*/
async function openConceptPanel(page,theme) {
await openConcept(page,'images',theme);await page.mouse.move(0,0);
if(await page.locator(CARDS).count()<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await settle(page);
}
async function openProductionPanel(page,theme) {
await openProduction(page,'images',theme);await page.mouse.move(0,0);
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await settle(page);
}
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
async function plainViewerChips(page) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
button.replaceWith(span);return [span,button];
});
});
await settle(page);
return async()=>{
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
await restoreTargets();
};
}
/** The controls an open "File & provenance" offers between its summary and the next disclosure's (m5). */
async function fileControlsOf(page) {
const disclosure=page.locator('#viewer-details details').first();
await disclosure.evaluate(node=>{node.open=true;});await settle(page);
const {controls}=(await inspectorOf(page)).viewerDetails;
await disclosure.evaluate(node=>{node.open=false;});await settle(page);
const names=controls.map(control=>control.name);
return controls.slice(names.indexOf('File & provenance')+1,names.indexOf('Workflow & raw metadata')).map(({tag,text,name})=>({tag,text,name}));
}
/**
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
const restoreMutation=mutation?await mutate(page,mutation):null;
if(mutation)await settle(page);
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
// The concept re-renders the Images page on the viewer's `close` event (app.js updateImages): let it land first.
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false) {
const {panel}=await inspectorOf(page);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */
async function fakeHost(context) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after}))},snapshot:roles}),
apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};},
}});
});
return host;
}
/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */
async function copyOf(page) {
// An earlier toast (the concept's Load more) is not the copy's outcome.
await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page);
await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';}));
await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click();
const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true});
await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe('');
const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0};
if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);}
return result;
}
/** The concept's send: its dialog previews both roles (app.js openSend). */
async function conceptSendOf(page) {
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const modal=page.locator('dialog#modal[open]');
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent]));
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
return previews;
}
/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */
async function productionSendOf(page,context) {
const host=await fakeHost(context);
try {
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender).toContainText('Sent to ComfyUI.');
const applied=await host.evaluate(()=>window.applied);
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
return applied;
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
}
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before);
await settle(page);
return {count:await page.locator('#result-count').textContent(),
cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
}
const historyOf=async page=>{
await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click();
await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/);
return page.evaluate(()=>location.hash);
};
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
test.setTimeout(180_000);
// The concept's copy and production's both use the async clipboard; Chromium asks for the permission.
if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']);
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
const viewport={width,height};
await openConceptPanel(page,theme);
await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`);
const reference=await inventory(page,true);
const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)};
await openConceptPanel(page,theme);concept.history=await historyOf(page);
await openProductionPanel(page,theme);
await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`);
const actual=await inventory(page);
const production={copy:await copyOf(page),send:await productionSendOf(page,context)};
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic');
production.tag=await tagOf(page);await listing;
// D44: the applied tag is one active refinement, so the toolbar's Filters button counts it.
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue();
await page.keyboard.press('Escape');
await openProductionPanel(page,theme);production.history=await historyOf(page);
const diff=compare(reference,actual,viewport);
const path=testInfo.outputPath('inspector-geometry.json');
await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2));
await testInfo.attach('inspector-geometry',{path,contentType:'application/json'});
// The concept itself is what this comparison assumes (spec §3.3, s523–615).
const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches);
expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28);
expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static');
expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200);
expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px');
expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px');
for(const section of panel.sections) {
expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'});
expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'});
for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'});
}
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false});
// Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them.
const [positive,negative]=concept.send;
expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']);
expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]);
expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution');
// Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images.
expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic');
// The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic
// (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order.
const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_'));
expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts');
expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged});
// Prompt history: the concept's `#prompt/<id>` is production's Library history of the linked positive.
expect.soft(concept.history,'concept history').toBe('#prompt/alpine');
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
});
// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its
// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its
// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags).
test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
test('inspector comparison detects mutations',async({page})=>{
test.setTimeout(120_000);
const viewport={width:1600,height:1100};
await page.setViewportSize(viewport);
await openConceptPanel(page,'dark');
const reference=await inventory(page,true);
await openProductionPanel(page,'dark');
const {viewerDetails,fileControls}=await inventory(page);
const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails,fileControls});
assertInspectorMatch(reference,await measure(),viewport);
const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'});
const cssMutated=await measure();
expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/);
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
await mutate(page,{remove:['#image-inspector > .detail-footer']});
const domMutated=await measure();
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
});
+340
View File
@@ -0,0 +1,340 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six
// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100
// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.library-grid .prompt-card';
const CONCEPT_PROMPTS=6;
/** Recorded Library deviations only (spec §8); everything else in the concept inventory must equal production. */
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
export function mapLibraryConcept(reference) {
const library=reference.library;
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's three sorts.
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
return item;
});
// A shorter toolbar moves what follows it up by as much.
const rise=library.toolbar.height-toolbar.height;
const cards=library.cards.map((card,index)=>({...card,
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
tags:sortedTags(card.tags),
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
// run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
return {...reference,library:{...library,toolbar,
// D35: the caption uses the singular for one.
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
// D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none.
trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}};
}
/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */
const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }';
const CLAMP_LINES=2;
/**
* D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the
* text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the
* grid's rows then close up by their tallest card's loss.
*/
export function clampedCards({grid,cards}) {
const shrunk=cards.map(card=>{
const text=card.section.text;
if(text.lines<=CLAMP_LINES)return {card,loss:0};
const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight));
return {loss,card:{...card,height:card.height-loss,
content:{...card.content,height:card.content.height-loss},
section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}},
meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss},
footer:{...card.footer,top:card.footer.top-loss}}};
});
// Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above.
const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b);
const rowLoss=top=>{
const row=cards.flatMap((card,index)=>card.top===top?[index]:[]);
return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height));
};
const above=top=>tops.filter(other=>other<top).reduce((sum,other)=>sum+rowLoss(other),0);
return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)},
cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))};
}
/** D43: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */
export function sortedTags(tags) {
return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text)));
}
const SECTIONS=['heading','library','noPageScroll'];
function compare(reference,actual,{sections=SECTIONS,library=null}={}) {
const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library
?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]]));
return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual)));
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertLibraryMatch(reference,actual) {
const diff=compare(reference,actual);
if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
// Two frames, then every running transition finished.
const settle=page=>page.evaluate(async()=>{
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
new Promise(resolve=>setTimeout(resolve,2000))]);
});
// Every card image decoded, so nothing moves while it is measured.
const decoded=page=>page.evaluate(async()=>{
await document.fonts.ready;
await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
});
const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant';
/**
* The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural
* reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own
* layout. The concept page is restored afterwards.
*/
async function conceptInventory(page) {
const reference=await inventoryOf(page,'#main');
await page.evaluate(({plural,singular})=>{
window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural);
for(const chip of window.__conceptPlurals)chip.textContent=singular;
},{plural:PLURAL_ONE,singular:SINGULAR_ONE});
const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta);
await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE);
return {...reference,oracle:{meta}};
}
/** The concept page with each card's tag chips in alphabetical order (the D43 oracle); returns the undo. */
async function sortConceptTags(page) {
await page.evaluate(()=>{
const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return x<y?-1:x>y?1:0;};
window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{
const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];});
});
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
}
async function openConceptLibrary(page,theme,motion) {
await openConcept(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function openProductionLibrary(page,theme,motion) {
await openProduction(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
// The category list is read once per visit; it is part of the toolbar inventory.
await expect(page.locator('#category-filter option')).toHaveCount(5);
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents();
const captionOf=page=>page.locator('.section-caption > span').first().textContent();
/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */
async function filteredOf(page,apply,undo) {
const before=await captionOf(page);
await apply();
await expect.poll(()=>captionOf(page)).not.toBe(before);
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
const result={caption:await captionOf(page),titles:await titlesOf(page)};
await undo();
await expect.poll(()=>captionOf(page)).toBe(before);
return result;
}
/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */
async function historyFrom(page,control,back) {
const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim();
await page.locator(CARDS).first().locator(control).click();
const h1=page.locator('h1#page-title');
await expect(h1).toHaveText(title);
const result={title,h1:(await h1.textContent()).trim()};
await back();
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
return result;
}
/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */
async function behaviorsOf(page,{back,sent}) {
const first=page.locator(CARDS).first();
const title=await historyFrom(page,'.card-title > button',back);
const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back);
// The footer send opens the sender with the positive prompt only.
const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim();
await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click();
const send={positive,roles:await sent(page)};
// Copy writes the full positive and shows the toast.
await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click();
await expect(page.locator('.toast')).not.toHaveText('');
const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))};
const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill(''));
const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption(''));
return {title,explore,send,copy,search,category};
}
/** The concept's demo sender: the roles its preview lists, with their text. */
async function conceptSent(page) {
const modal=page.locator('dialog#modal[open]');
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
return roles;
}
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
function productionSent(host) {
return async page=>{
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
const roles=await host.evaluate(()=>window.previewed.pop());
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
return roles;
};
}
async function openHost(context) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
window.previewed=[];
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
apply:roles=>({targetId:target.id,values:roles}),
}});
});
return host;
}
test.beforeEach(async({page,request},testInfo)=>{
testInfo.manifest=await seedConcept(request);
// Both documents copy through the clipboard; the stub records what they wrote.
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
test.setTimeout(240_000);
await page.setViewportSize({width,height});
const narrow=width<=700;
const host=await openHost(context);
try {
const result={};
for(const motion of MOTIONS) {
const capture=motion==='no-preference';
await openConceptLibrary(page,theme,motion);
const reference=await conceptInventory(page);
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
// The browser as the re-layout's oracle: the concept page itself with the D23 clamp applied to its cards.
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
const clamped=(await inventoryOf(page,'#main')).library;
await restoreClamp();
// ...and with every card's tag chips put in alphabetical order (D43).
const restoreTags=await sortConceptTags(page);
const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags);
await restoreTags();
const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null;
await openProductionLibrary(page,theme,motion);
const actual=await inventoryOf(page,'#workspace-main');
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
result[motion]={reference,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
}
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
const {grid,cards}=reference.library,card=cards[0];
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
const art=narrow?150:170;
expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'});
const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat);
expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1);
expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]);
const pad=narrow?'15px':'18px';
expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad});
expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'});
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
if(!concept)continue;
// Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes
// the full positive with the concept's toast; search and category narrow the shelf alike.
expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text});
expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive});
expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive});
expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']);
// D35: the shelf caption uses the singular for one.
const counted=filter=>({...filter,caption:countCopy(filter.caption)});
expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)});
}
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width:1800,height:1100});
await openConceptLibrary(page,theme,'no-preference');
const reference=await conceptInventory(page);
await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`);
await openProductionLibrary(page,theme,'no-preference');
const actual=await inventoryOf(page,'#workspace-main');
await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`);
expect(reference.library.grid.columns,'concept columns').toBe(3);
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
test('library comparison detects mutations',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConceptLibrary(page,'dark','no-preference');
const reference=await conceptInventory(page);
await openProductionLibrary(page,'dark','no-preference');
const measure=()=>inventoryOf(page,'#workspace-main');
assertLibraryMatch(reference,await measure());
const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'});
const cssMutated=await measure();
expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/);
await restoreCss();await settle(page);
assertLibraryMatch(reference,await measure());
await mutate(page,{remove:['.card-meta .chip.red']});
const domMutated=await measure();
expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/);
});
+2 -2
View File
@@ -18,8 +18,8 @@ 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);
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
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-history .timeline-panel':'dialog.library-editor');
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
+92 -40
View File
@@ -2,9 +2,9 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mj
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^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();
@@ -58,8 +58,8 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0);
const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope);
const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json();
await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text);
await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
await select(page,'0:shared/same.png');await page.locator('.media-card.selected .image-open').click();await expect(preview).toContainText(evidence.prompts.positive.text);
await preview.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]);
} finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
@@ -67,17 +67,18 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
});
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
await page.goto(workspace);await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toBeEnabled();
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
// Every inspector send is named after its prompt ("…to ComfyUI: <title>"); an untitled one ends at "ComfyUI".
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI(: |$)/})).toHaveCount(0);
});
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
@@ -100,7 +101,7 @@ test('inspection failures retry and a delayed previous selection cannot replace
test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
@@ -111,21 +112,53 @@ 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 page.locator('.media-card.selected .image-open').click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
await page.getByRole('button',{name:'Close 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 to ComfyUI: /})).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 {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
@@ -135,9 +168,9 @@ test('host apply rejection retains the current text and requires a new preview',
test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await page.locator('.media-card.selected .image-open').click();
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});await send.click();await expect(dialog(page)).toBeVisible();
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
});
@@ -146,7 +179,7 @@ test('empty negative send clears only the negative destination and preserves its
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:empty-negative.png');
await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
await details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
@@ -155,7 +188,7 @@ test('empty negative send clears only the negative destination and preserves its
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
const host=await fakeHost(context,page);
try {
@@ -171,12 +204,13 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).toHaveText(text);
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);
await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Close dialog',exact:true}).click();
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
});
@@ -195,8 +229,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 +250,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);
});
@@ -244,20 +279,37 @@ test('unresolved roles name the blocking node as text and offer no prompt action
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
});
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
test('prompt role headings contain the concept label and compact real actions without a source line',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
for (const name of ['Positive','Negative']) {
const block=role(details(page),name),heading=block.locator('.prompt-block-heading');
await expect(heading.getByRole('heading',{name:`${name} prompt`,exact:true})).toBeVisible();
await expect(heading.getByRole('button',{name:`Copy ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
await expect(heading.getByRole('button',{name:`Send ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
for (const [name,label] of [['Positive','+ Positive prompt'],['Negative','\u2212 Negative prompt']]) {
const block=role(details(page),name),heading=block.locator('.prompt-heading');
await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`));
await expect(heading.locator('span.role-label')).toHaveText(label);
await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`));
await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`));
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true);
expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true);
}
await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow');
await expect(details(page)).not.toContainText('Inferred from the saved workflow');
await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
await expect(role(details(page),'Negative').locator('.prompt-block-heading [data-copy-role]')).toHaveCount(1);
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(role(details(page),'Negative').locator('.prompt-heading [data-copy-role]')).toHaveCount(1);
await select(page,'0:uncertain.png');await expect(details(page).locator('[data-copy-role]')).toHaveCount(0);
await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).locator('p.prompt-text.muted').first()).toHaveText('Not recorded in this file.');
});
test('copy toast and fallback dialog',async({page})=>{
await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});});
await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent();
const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click();
await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]);
await expect(positive.getByRole('status')).toHaveCount(0);
await page.evaluate(()=>{window.clipboardFails=true;});await copy.click();
const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback).toBeVisible();
const area=fallback.getByRole('textbox',{name:'Select and copy this text',exact:true});
await expect(area).toHaveValue(text);await expect(area).toBeFocused();
expect(await area.evaluate(node=>[node.readOnly,node.selectionStart,node.selectionEnd])).toEqual([true,0,text.length]);
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
});
+114
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 (
@@ -71,6 +76,53 @@ class TestModel:
return ["generated", Path(image_path).parent.parent.name]
REVEAL_OUTCOMES = ("opened", "unavailable", "fail")
def register_reveal_stub(routes, api, root: Path):
"""D38: Show in folder never opens a real file manager here.
The API's opener records the resolved path; ``POST /__test__/reveal``
sets the next outcome and ``GET /__test__/reveal`` lists the opened paths.
"""
from utils.file_reveal import OpenerUnavailable, RevealGate
state = {"outcome": "opened", "opened": []}
def opener(path: Path, gallery_root: Path) -> None:
if state["outcome"] == "unavailable":
raise OpenerUnavailable()
if state["outcome"] == "fail":
raise OSError("stubbed start failure")
state["opened"].append(Path(path).relative_to(root.resolve()).as_posix())
api._reveal_opener = opener
# Each click gets an answer here; the cooldown is a unit-tested default.
api._reveal_gate = RevealGate(cooldown=0)
@routes.post("/__test__/reveal")
async def set_reveal_outcome(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else None
if (
not isinstance(data, dict)
or set(data) != {"outcome"}
or data["outcome"] not in REVEAL_OUTCOMES
):
raise web.HTTPBadRequest()
state["outcome"] = data["outcome"]
return web.json_response({"success": True})
@routes.get("/__test__/reveal")
async def reveal_calls(request: web.Request) -> web.Response:
return web.json_response({"opened": state["opened"]})
def reset() -> None:
state["outcome"] = "opened"
state["opened"].clear()
return types.SimpleNamespace(reset=reset)
def create_app(root: Path) -> web.Application:
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
os.chdir(root)
@@ -106,6 +158,7 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
@@ -128,6 +181,8 @@ def create_app(root: Path) -> web.Application:
) -> None:
with api.db.model.get_connection() as conn:
for table in (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -305,6 +360,7 @@ def create_app(root: Path) -> web.Application:
history_fixture = manifest
return web.json_response({"success": True, **manifest})
history_commands.reset()
reveal_stub.reset()
workspace_visual = data.get("workspace_visual", False)
full_inspector = data.get("full_inspector", False)
extra = data.get("extra_images", 0)
@@ -379,6 +435,64 @@ 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()
reveal_stub.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__/saved-views/raw")
async def insert_raw_saved_view(request: web.Request) -> web.Response:
"""Store a view row as an older build wrote it, bypassing the API's rules."""
data = await request.json()
if (
not isinstance(data, dict)
or set(data) != {"page", "name", "state"}
or not all(isinstance(data[key], str) for key in data)
):
raise web.HTTPBadRequest()
def insert() -> int:
with api.db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO saved_views (page, name, state, position, "
"created_at, updated_at) VALUES (?, ?, ?, 0, 'x', 'x')",
(data["page"], data["name"], data["state"]),
).lastrowid
return web.json_response(
{"success": True, "id": await api._run_in_executor(insert)}
)
@routes.post("/__test__/add-nested-image")
async def add_nested_image(request: web.Request) -> web.Response:
destination = root / "output" / "project" / "day" / "new-output.png"
+291
View File
@@ -0,0 +1,291 @@
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';
/**
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
*/
function savedNav(nav) {
const rows=nav.rows.filter(row=>row.text!=='Needs review');
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
}
/** 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: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
.filter(nav=>nav.id!=='collections-nav')
.map(nav=>{
if(nav.id==='saved-nav')return savedNav(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: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
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);
}
}
});
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
await page.setViewportSize({width,height});
const save=async(action,mainSelector)=>{
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
await dialog.getByRole('button',{name:action,exact:true}).click();
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
await page.mouse.move(0,0);
return inventoryOf(page,mainSelector);
};
await openConcept(page,'images','dark');
const reference=await save('Apply in prototype','#main');
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
await openProduction(page,'images','dark');
const actual=await save('Save','#workspace-main');
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
});
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 #image-inspector .meta-grid');
await expect(facts).toBeVisible();
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
// app.js:404-408: the concept's facts for alpine_00001.png.
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
// D21: execution evidence is the concept's "Execution recorded" badge.
await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded');
});
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
// 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);}
+354 -44
View File
@@ -9,8 +9,9 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery.
const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');};
// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the
// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter).
const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');};
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
const exact=async locator=>locator.evaluate(node=>node.textContent);
@@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
try {
@@ -128,10 +129,10 @@ test('archive delete merge and prune preserve frozen evidence without repairing
});
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original);
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(original);
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID');
expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404);
@@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
await open(page,fixture.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
const selected=detail.variants.find(v=>v.negative_text==='A negative').title;
await page.getByRole('button',{name:`Select negative ${selected}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
@@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
await expect(marker(page,id)).toBeFocused();
}
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
await expect(page.getByRole('button',{name:`Select negative ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
});
@@ -199,18 +200,20 @@ test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode a
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
expect(await exact(pos.locator('p.prompt-text'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.raw);
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.effective);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('p.prompt-text'))).toBe(' \n\t ');
// D22: the frozen block has no source line; each field's source stays in "Frozen field provenance".
await expect(unknown).not.toContainText('host_cache_output');await expect(dialog(page).locator('.recorded-provenance')).toContainText(/Effective · \w+ · host_cache_output/);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
@@ -224,7 +227,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 +248,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 +262,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);
@@ -280,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me
});
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',exact:true}).click();
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
let writes=0,galleryReads=0,outputReads=0;
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
@@ -297,9 +300,9 @@ 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');
await expect(page.getByRole('button',{name:'Select negative Saved empty',exact:true})).toHaveAttribute('aria-pressed','true');
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
});
@@ -353,7 +356,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();
@@ -378,7 +381,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await reviewCapture(page,testInfo,`outside-selected-dialog-${width}-${theme}`,dialog(page));
const hashes={};
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/library.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/workspace.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
expect(hashes['docs/concepts/workspace/index.html']).toBe('d84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03');expect(hashes['docs/concepts/workspace/app.js']).toBe('e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e');expect(hashes['docs/concepts/workspace/style.css']).toBe('2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8');
const manifest=testInfo.outputPath('final-fix-visual-source-and-scroll.json');await writeFile(manifest,JSON.stringify({width,height,theme,oldRun,before,after,hashes},null,2));
await testInfo.attach('final-fix-visual-source-and-scroll.json',{path:manifest,contentType:'application/json'});
@@ -397,7 +400,7 @@ test('history, run and output failures retry their own reads and route exit igno
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
@@ -447,7 +450,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
});
try {
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
@@ -466,16 +469,16 @@ test('the inline current file names its prompts apart from the frozen run fields
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive');
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative');
await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible();
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
@@ -485,7 +488,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
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.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');
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.
@@ -500,7 +503,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
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(()=>{});});
@@ -562,7 +565,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run
});
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
@@ -573,8 +576,8 @@ test('history epoch recovery reports saved-parent read failures and retries with
});
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text'));
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
@@ -583,7 +586,7 @@ test('changed saved-owner identity is disclosed without silently relabeling or r
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('p.prompt-text'))).toBe(original);
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
@@ -593,7 +596,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
@@ -606,7 +609,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
@@ -615,7 +618,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation');
await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0);
await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden();
await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1);
await expect(choices.first()).toBeEnabled();await expect(page.locator('.variant-dot[aria-pressed="true"]')).toHaveCount(1);
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);
});
@@ -623,15 +626,15 @@ test('an authoritative save after a stale revalidation makes the saved view curr
async function retainSavedSelection(page,request,fixture) {
await open(page,fixture.detail_id);
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title;
await page.getByRole('button',{name:`Select negative ${name}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'));
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'));
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
return async()=>{
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative);
await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative);
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
};
}
@@ -702,7 +705,7 @@ test('capture-only recovery uses the changed real output capture without re-read
const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting');
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2);
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
});
test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{
@@ -719,3 +722,310 @@ test('completed page one stays bounded across mode changes until explicit output
await page.keyboard.press('Escape');await retained();
} finally {releaseNext();}
});
// #249 PR F1: the concept heading (app.js a591–597) and summary surface (a598) with real counts (D24).
const historyHeading=page=>page.locator('#workspace-main > .heading');
const summary=page=>page.locator('#workspace-main .library-history article.surface');
test('history heading actions stay on one row at 1600 and are enabled after load',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const f=page.fixture;
let releaseDetail;const held=new Promise(resolve=>{releaseDetail=resolve;});
await page.route(new RegExp(`/prompt_manager/library/${f.positive_id}\\?`),async route=>{await held;await route.continue().catch(()=>{});});
try {
await page.goto(`/prompt_manager/workspace#/library/${f.positive_id}`);
const heading=historyHeading(page),send=heading.locator('.heading-actions > button.send');
await expect(heading.locator('p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
// D24: disabled only while the selected pair's roles are loading.
await expect(send).toBeDisabled();releaseDetail();
await expect(recorded(page)).toBeVisible();await expect(send).toBeEnabled();
} finally {releaseDetail();}
const heading=historyHeading(page),title=await heading.locator('h1').textContent();
const actions=heading.locator('.heading-actions > button');
await expect(actions).toHaveCount(3);
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()))).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
await expect(actions.nth(2)).toHaveClass('send');
await expect(actions.nth(2)).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${title}`);
for(const action of [actions.nth(0),actions.nth(1)]) await expect(action).toBeEnabled();
const boxes=await actions.evaluateAll(nodes=>nodes.map(node=>{const box=node.getBoundingClientRect();return {y:box.y,height:box.height};}));
expect(new Set(boxes.map(box=>box.y)).size).toBe(1);
const row=await heading.locator('.heading-actions').evaluate(node=>node.getBoundingClientRect().height);
expect(row).toBe(boxes[0].height);
});
test('summary meta uses recorded runs',async({page,request})=>{
const f=page.fixture;await open(page,f.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${f.positive_id}`)).json(),runs=(await historyRead(request,f.positive_id)).recorded_run_count;
const surface=summary(page),title=await historyHeading(page).locator('h1').textContent();
await expect(surface).toHaveCount(1);
await expect(surface.locator(':scope > span.chip')).toHaveText(detail.positive.category || 'Uncategorized');
const plural=(count,word)=>`${count} ${count===1?word:`${word}s`}`;
await expect(surface.locator(':scope > .card-meta > span')).toHaveText([plural(runs,'tracked run'),plural(detail.positive.image_count,'image'),plural(detail.positive.variant_count,'negative variant')]);
// The concept order: category chip, positive block, selected negative block, then the meta line.
expect(await surface.evaluate(node=>[...node.querySelectorAll('.chip, .prompt-section, .card-meta')].filter(item=>item.checkVisibility()).map(item=>item.className))).toEqual(['chip','prompt-section positive','prompt-section negative','card-meta']);
for(const name of ['Copy positive prompt','Send positive prompt to ComfyUI','Copy negative prompt','Send negative prompt to ComfyUI'])
await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible();
await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px');
});
// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26).
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true});
test('variants panel matches the concept',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
await expect(panel).toHaveClass('surface timeline-panel');
const heading=panel.locator(':scope > .panel-heading');
await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants');
await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.');
const actions=heading.locator(':scope > .row-actions > button');
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']);
const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false');
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');
const timeline=panel.locator(':scope > .timeline');
await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged');
const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3);
// Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i.
const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs'];
for(const [index,name] of names.entries()) {
const lane=lanes.nth(index);
await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`);
const dot=lane.locator(':scope > button.variant-dot');
await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`);
await expect(dot).toHaveAttribute('aria-pressed',String(index===0));
await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`);
expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']);
}
await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false');
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2);
const lower=panel.locator(':scope > .timeline-lower');
await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run');
await expect(lower.locator(':scope > button')).toHaveText('All images for this positive');
await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/);
await expect(panel).not.toContainText('Saved combinations, not an execution timeline.');
// The concept tokens reach the lanes (style.css 1096-1137).
await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px');
await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%');
await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px');
});
test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2);
await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0);
// D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target.
for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) {
const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]);
expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x);
}
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded();
const reach=await dot.evaluate(node=>{
const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2;
return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;});
});
expect(reach).toEqual(['dot','dot','dot','dot']);
}
// The baseline has a recorded execution: the dot opens Recorded run for it.
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click();
await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
// Clean geometry was counted but never recorded: Repeat run shows its newest linked image only.
const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true});
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
await clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.');
await expect(repeat.locator('#modal-body > .spaced-15 article[data-image-key]')).toHaveCount(1);
// D47: as in the concept (a1291-1304), the dialog title names the image; no collection heading or count repeats it.
await expect(repeat.locator('.section-caption')).toHaveCount(0);
await expect(repeat.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(repeat.getByText(/ loaded · /)).toHaveCount(0);
expect(await repeat.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Repeat run']);
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('limit')).toBe('1');
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused();
});
test('all images dialog lists every image for the positive',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);
const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
// Opening on the first lane, the shared gallery shows that lane only.
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible();
await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
// Leaving the route closes an open dialog and its gallery.
await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0);
});
test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
const compare=panel.getByRole('button',{name:'Compare negatives',exact:true});
const section=page.locator('.library-history .timeline-panel + section.surface');
await expect(section).toHaveCount(0);
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');
// The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25).
await expect(section).toHaveCount(1);
expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']);
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.');
const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2);
await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark');
await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0);
// D46: the two blocks are named apart, so no region or action name repeats on the page.
await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible();
await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible();
await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms');
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
await expect(section.locator('.diff-added')).toHaveCount(0);
// Another dot updates the same section in place.
const node=await section.elementHandle();
await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click();
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.');
await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light');
await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']);
await expect(section.locator(':scope > p.small')).toHaveCount(1);
expect(await node.evaluate(element=>element.isConnected)).toBe(true);
await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block');
await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px');
await page.setViewportSize({width:1600,height:1100});
let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1);
await page.setViewportSize({width:390,height:844});
boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height);
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click();
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.');
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0);
});
test('compare waits for the page holding the oldest active negative',async({page})=>{
const f=page.fixture;
// A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46).
await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{
const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue();
const response=await route.fetch(),body=await response.json();
await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}});
});
await open(page,f.canonical_id);const panel=variantsPanel(page);
await panel.getByRole('button',{name:'Compare negatives',exact:true}).click();
const section=page.locator('.library-history .timeline-panel + section.surface');
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.');
await expect(section.locator('.compare-grid')).toHaveCount(0);
await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`));
});
test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{
const f=page.fixture;
await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}}));
await open(page,f.canonical_id);const panel=variantsPanel(page);
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});
await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.');
await expect(dialog(page)).toHaveCount(0);
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused();
await page.unroute(/\/history\/pairs\/\d+\/newest/);
// The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast.
await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}}));
await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';});
const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url()));
await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
expect((await(await newest).json()).run.run_id).toBe(f.canonical_run);
await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#workspace-toast')).toHaveText('');
});
test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{
const f=page.fixture;
await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK();
await open(page,f.canonical_id);const panel=variantsPanel(page);
await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click();
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click();
const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1);
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived');
// Each open replaces the previous dialog and its gallery.
await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1);
});
test('Compare negatives is disabled without an active variant',async({page,request})=>{
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK();
await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true});
await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false');
await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0);
});
test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id]));
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK();
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK();
await open(page,f.canonical_id);
const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));
expect(new Set(names).size).toBe(names.length);
expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']);
});
// #249 F4: the concept's shared gallery (app.js a616-621, style.css 1546-1548) of PR C media cards (C3 activation).
test('shared gallery uses concept cards, image opens the viewer, empty copy',async({page,request})=>{
const f=page.fixture;await open(page,f.canonical_id);
const section=gallery(page),heading=section.locator(':scope > .panel-heading');
expect(await section.evaluate(node=>`${node.tagName.toLowerCase()}.${node.className}`)).toBe('section.history-gallery');
await expect(heading.locator(':scope > h2')).toHaveText('Shared gallery');
// The page opens on the newest lane, a saved variant with no runs: the concept's empty copy.
await expect(heading.locator(':scope > span.small')).toHaveText('No negative · same positive prompt');
await expect(section.locator('.empty')).toHaveText('Saved variant · no runs yet.');await expect(section.locator('.empty')).toBeVisible();
await expect(section.locator('article[data-image-key]')).toHaveCount(0);
// D47 (maintainer ruling 2026-10-21): the concept's "Shared gallery" heading is the gallery's only heading, so the
// collection's "Linked images" heading and its "N loaded · N linked images" count are gone; the layout row and the
// selected-image side panel stay.
await expect(section.locator('.section-caption')).toHaveCount(0);
await expect(section.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(section.getByText(/ loaded · /)).toHaveCount(0);
// Outside the side panel (whose own heading names the selected image), the gallery has one section heading.
expect(await section.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Shared gallery']);
await expect(section.getByRole('group',{name:'Image layout',exact:true})).toBeVisible();
await expect(section.getByRole('combobox',{name:'Thumbnail size',exact:true})).toBeVisible();
await expect(section.getByRole('complementary',{name:'Selected image details',exact:true})).toBeVisible();
// As in the concept, the gallery follows the variants panel; recorded executions (#209) stay below it.
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].map(item=>item.getAttribute('aria-label')).filter(name=>['Saved negative variants','Shared gallery','Recorded executions'].includes(name)));
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
const dot=variantsPanel(page).getByRole('button',{name:'Select negative Quality baseline',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
await expect(heading.locator(':scope > span.small')).toHaveText('Quality baseline · same positive prompt');
const cards=section.locator('article.media-card[data-image-key]');await expect(cards).toHaveCount(2);
await expect(section.locator('.empty')).toBeHidden();
const details=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
const pair=details.variants.find(variant=>variant.title==='Quality baseline').id;
const listing=await(await request.get(`/prompt_manager/library/${f.canonical_id}/images?pair_id=${pair}`)).json();
const first=cards.first(),filename=(await first.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
const image=listing.images.find(item=>item.filename===filename);expect(typeof image.favorite).toBe('boolean');
// The concept card: art (open button + overlays), then the info row; the star where the read returns favorite and
// no checkbox (the selection bar stays Images-only, #259).
expect(await first.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['media-art','media-info']);
expect(await first.locator(':scope > .media-art').evaluate(node=>[...node.children].map(child=>`${child.tagName.toLowerCase()}.${child.className}`))).toEqual(['button.image-open','div.image-overlays']);
await expect(first.locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
const star=first.locator('.image-overlays > button.star-button');
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
await expect(star).toHaveText(image.favorite?'★':'☆');
await expect(first.locator('.media-info h3')).toHaveCount(1);
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
// C3 activation: the image opens the viewer and Escape returns focus to it.
const openButton=first.locator('.image-open');await openButton.click();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await page.keyboard.press('Escape');await expect(viewer).toBeHidden();await expect(openButton).toBeFocused();
await expect(section.locator(':scope > .panel-heading')).toHaveCSS('justify-content','space-between');
await expect(section).toHaveCSS('margin-top','24px');
// D47 (maintainer ruling 2026-10-21) covers the history page's dialogs too: the All images dialog is named by its
// own title, with no "Linked images" heading or "N loaded · N linked images" count, as the concept's (a1282-1290).
await variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
const all=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await expect(all.locator('article[data-image-key]').first()).toBeVisible();
await expect(all.locator('.section-caption')).toHaveCount(0);
await expect(all.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(all.getByText(/ loaded · /)).toHaveCount(0);
expect(await all.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['All images for this positive']);
await page.keyboard.press('Escape');await expect(all).toHaveCount(0);
// At 390px the heading stays inside the gallery and the page never scrolls sideways.
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
const [box,text]=await Promise.all([section.boundingBox(),heading.locator(':scope > span.small').boundingBox()]);
expect(text.x+text.width).toBeLessThanOrEqual(box.x+box.width+0.5);
});
File diff suppressed because it is too large Load Diff
+84 -32
View File
@@ -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,12 +433,12 @@ 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');
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]');
async function openHistory(page,id) {
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
}
@@ -450,14 +450,14 @@ async function leaveAndReturn(page,id) {
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
// One linked image is counted in the singular.
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1);
// One image is counted in the singular.
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
@@ -472,14 +472,15 @@ test('library heading offers Export then + New prompt, and New opens the saved p
// Exactly one saved negative is counted in the singular, in history and on the shelf card.
await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1);
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('− 1 negative variant',{exact:true})).toHaveCount(1);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
});
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);
@@ -496,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
@@ -508,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy
expect(page.writes).toHaveLength(0);
});
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await selectPair(page,'Select negative variant 2');
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
// #249 D26: All images opens the concept dialog and leaves the selected negative as it was.
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible();
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await leaveAndReturn(page,f.positive_id);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
});
@@ -530,8 +533,8 @@ test('add negative re-renders history with the saved or reused pair selected',as
await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
@@ -549,9 +552,9 @@ test('reused pair outside first forty remains selected without loading every pag
await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0;
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative);
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41);
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
});
@@ -564,7 +567,7 @@ test('edit metadata stays on the page and re-reads the selected pair',async({pag
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(toast(page)).toHaveText('Saved.');
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('article.surface > span.chip').first()).toHaveText('Studio work');
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
@@ -638,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
});
// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis.
test('show archived and restore still work',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const timeline=variantsPanel(page).locator(':scope > .timeline');
expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1);
await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/);
const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−');
expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1);
expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1);
await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1');
await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
const writes=page.writes.length;
await lane.getByRole('button',{name:/^Restore /}).click();
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible();
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1);
});
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
let release;const held=new Promise(resolve=>{release=resolve;});
@@ -667,26 +693,30 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
expect(await evidence()).toEqual(before);
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
const lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1);
const opacity=await lane.locator('.variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
// #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);
// D47: the archived pair's gallery is named by its "Shared gallery" heading, with no separate loaded count.
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.panel-heading .small')).toHaveText(/ · Archived · same positive prompt$/);
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();
const writes=page.writes.length;
await lane.getByRole('button',{name:/^Restore/}).click();
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]);
expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
@@ -694,14 +724,14 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
});
test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone');
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Standalone');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);});
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click();
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter');
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone');
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Standalone');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible();
expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true);
@@ -724,7 +754,7 @@ test('export downloads the full Library JSON without a toast and reports a failu
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
});
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
@@ -735,8 +765,8 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative);
await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative);
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
await uniqueDotNames(page);
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
@@ -744,7 +774,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
@@ -805,7 +835,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
});
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
@@ -866,3 +896,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/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);
}
});
+348 -85
View File
@@ -7,13 +7,32 @@ const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const media=page=>gallery(page).locator('article[data-image-key]');
const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
/** The Library's Tags filter lives in "Refine this view" (#249 D14). */
async function applyTags(page,tags) {
// D44: Filters reads "Filters · 1" while a Tags filter is active.
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:/^Filters(?: · 1)?$/}).click();
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill(tags);
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
}
/** The first shelf page, then `pages` more, each waited for by its own response (the caption counts the filtered set). */
async function loadShelfPages(page,more,pages) {
await expect(page.locator('.section-caption')).toContainText('203 positive prompts');await expect(more).toHaveAttribute('aria-disabled','false');
for(let index=0;index<pages;index++) {
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');
}
}
async function openHistory(page,text='Cat portrait') {
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
await expect(history(page)).toBeVisible();
}
// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position.
const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot');
async function selectVariant(page,index) {
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
const choice=variantDot(page,index);
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
}
@@ -23,7 +42,8 @@ test.beforeEach(async({page,request,browserName})=>{
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();
await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1);
@@ -31,7 +51,8 @@ test('library groups reusable negatives under one positive without exposing nega
await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0);
const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})});
await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i);
await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i);
// D23: "N runs" is the parent's own run_count, never a recorded-execution count.
for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
});
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
@@ -39,31 +60,34 @@ test('history displays the selected full negative beneath its positive and filte
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
await page.goto(route);await openHistory(page);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
// #249 D26: All images for this positive opens a dialog; it is not a gallery filter.
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/);
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
await expect(neg).toContainText(negative||'Empty negative prompt');
await expect(neg.locator('p.prompt-text')).toHaveText(negative||'No negative prompt (explicitly empty)');
await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
await expect(positive).toContainText('Cat portrait');
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
expect(order&4).toBeTruthy();
}
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3);
expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0);
});
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape');
await page.goto(route);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('');
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits');
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1);
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('');
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
await applyTags(page,'outdoors');await expect(cards(page)).toHaveCount(0);
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
});
test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{
@@ -83,10 +107,34 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
const firstId=await cards(page).first().getAttribute('data-prompt-id');
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true});
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('button',{name:'Explore history',exact:true});
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
});
// D40/D15: with no Refresh, Retry also re-reads a category list that failed with the shelf.
test('Retry re-reads a failed category list with the shelf',async({page})=>{
let fail=true;const reads=[];
await page.route(url=>['/prompt_manager/library','/prompt_manager/library/options'].includes(url.pathname),async request=>{
reads.push(new URL(request.request().url()).pathname);
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
await request.continue().catch(()=>{});
});
await page.goto(route);
const retry=page.getByRole('button',{name:'Retry prompt library',exact:true});await expect(retry).toBeVisible();
const categoriesFailed=page.getByRole('alert').filter({hasText:'Categories could not be loaded. Open the Prompt library again to retry.'});
await expect(categoriesFailed).toBeVisible();
fail=false;reads.length=0;await retry.click();await expect(cards(page)).toHaveCount(3);
await expect(categoriesFailed).toBeHidden();
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true}).locator('option',{hasText:'Portraits'})).toHaveCount(1);
expect(reads).toContain('/prompt_manager/library/options');
// Apply with the same Tags, and a search that lands on the values already shown, reload nothing.
reads.length=0;
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/library?*',async request=>{
@@ -97,11 +145,82 @@ test('failed library reads retry while late search responses cannot overwrite ne
});
await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
const sorted=page.waitForRequest(item=>new URL(item.url()).searchParams.get('sort')==='name');
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption({label:'Name A–Z'});await sorted;
});
test('caption counts equal the filtered cards',async({page,request})=>{
const totals=async query=>{const data=await(await request.get(`/prompt_manager/library?${query}`)).json();return [data.items.length,data.items.reduce((sum,item)=>sum+item.variant_count,0),data.variant_total];};
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const caption=page.locator('#workspace-main .section-caption');
for(const [query,act] of [['',null],['category=Portraits',()=>page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits')],['category=Portraits&tag=outdoors',()=>applyTags(page,'outdoors')]]) {
if(act)await act();
const [count,variants,total]=await totals(query);expect(variants).toBe(total);
await expect(cards(page)).toHaveCount(count);
await expect(caption.locator('span')).toHaveText([`${count} positive prompt${count===1?'':'s'} · ${variants} negative variant${variants===1?'':'s'}`,'Images are grouped by positive prompt']);
}
});
// #249 final review M5: the count is a status region, written only when it changes, so Load more never re-announces it.
test('the caption count is announced once per change and kept while the shelf reloads',async({page,request})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await page.goto(route);const more=page.getByRole('button',{name:'Load more prompts',exact:true});
const count=page.locator('#workspace-main .section-caption > span').first();
await expect(count).toHaveAttribute('role','status');await expect(count).toHaveText(/^203 positive prompts · \d+ negative variants?$/);
await count.evaluate(node=>{window.__countWrites=0;new MutationObserver(records=>{window.__countWrites+=records.length;}).observe(node,{childList:true,characterData:true,subtree:true});});
await loadShelfPages(page,more,1);
expect(await page.evaluate(()=>window.__countWrites)).toBe(0);
// A filter change keeps the last count shown while the next page loads, then writes the new one.
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(url=>url.pathname==='/prompt_manager/library'&&url.searchParams.get('category')==='Portraits',async route=>{await held;await route.continue();});
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
await expect(page.locator('.library-status')).toHaveText('Loading prompt library…');
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();await expect(count).toHaveText(/^203 positive prompts/);
release();await expect(count).not.toHaveText(/^203 /);
expect(await page.evaluate(()=>window.__countWrites)).toBeGreaterThan(0);
});
test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{
await page.goto(route);await openHistory(page);
// The unassigned file is reached through the All images dialog (#249 D26).
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3);
const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true});
await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0);
await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0);
const saved=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/images/favorite'&&item.request().method()==='PUT');
await star.click();expect((await saved).ok()).toBe(true);
await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★');
await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible();
await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
});
test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id');
await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK();
@@ -111,49 +230,52 @@ test('refresh atomically discards old row geometry and renders changed parent da
window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);});
});
try {
await page.getByRole('button',{name:'Refresh library',exact:true}).click();
// D15: no Refresh; any filter change restarts the stream the same way.
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');
await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…');
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
await expect(cards(page)).toHaveCount(3);
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1);
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{
await page.setViewportSize({width:390,height:844});await page.goto(route);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Animals');
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
await openHistory(page);await selectVariant(page,0);
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{
await page.setViewportSize({width:1600,height:300});await page.goto(route);
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive');
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive');
await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.');
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
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 expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
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 workspace',exact:true})).toHaveValue('No matching saved positive');
await expect(cards(page)).toHaveCount(0);
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape');
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.focus();await search.fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused();
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1);
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');await expect(cards(page)).toHaveCount(1);
expect(await page.evaluate(()=>scrollY)).toBe(0);
const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
// A short phone window, so the one-card shelf can scroll far enough to put the parent back at its offset (nothing
// follows the shelf's continuation since the legacy link moved to Overview, #249 E3).
await page.setViewportSize({width:390,height:600});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape');
});
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
@@ -163,7 +285,7 @@ test('history detail failures retry and returning to the list ignores late detai
await hold;await route.continue().catch(()=>{});
});
try {
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release();
@@ -187,8 +309,8 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id');
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
@@ -197,24 +319,26 @@ 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();
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 media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click();
const inspector=page.getByRole('complementary',{name:'Selected image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
await expect(inspector.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
// D20: provenance and raw records are the viewer's full details, opened from the card image (D19).
await expect(inspector.locator('summary')).toHaveCount(0);
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect(page.locator('dialog[open] #viewer-details summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.getByRole('navigation',{name:'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();}
});
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
await page.goto(route);await openHistory(page);
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative);
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative);
});
async function observeLibrary(page, hold=false) {
@@ -231,7 +355,7 @@ async function observeLibrary(page, hold=false) {
record.native=new NativeResizeObserver(entries=>{
const ready=[];
for(const entry of entries) {
if(entry.target.matches('.library-card')) {
if(entry.target.matches('.prompt-card')) {
if(!entry.target.isConnected)unmounted++;
record.latest.set(entry.target,entry);
if(holding) {record.entries.set(entry.target,entry);continue;}
@@ -245,7 +369,7 @@ async function observeLibrary(page, hold=false) {
unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);}
disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();}
};
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.prompt-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
window.libraryObservation={
hold(){holding=true;},
release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}},
@@ -273,37 +397,40 @@ async function retainedParent(page, anchor) {
await expect.poll(async()=>Math.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2);
}
test('Library natural rows retain long prompt content without overflow',async({page,request})=>{
test('Library cards clamp long prompts and keep real content inside the card',async({page,request})=>{
await request.post('/__test__/reset',{data:{workspace_visual:true}});
const data=await(await request.get('/prompt_manager/library')).json();
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6);
expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
await expect(page.locator('.workspace-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 expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await expect(page.locator('.prompt-section.positive .role-label').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
}
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
await expect(page.locator('.prompt-card .card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
await expect(page.locator('.card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
await expect(page.locator('.library-grid')).toHaveCSS('gap','18px');
const long=cards(page).filter({hasText:'A study in very long creative context'});
const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500);
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) {
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true);
// D23: two lines of the concept's 12px/1.75 prompt text, the rest clipped inside the card.
expect(await prompt.evaluate(node=>({height:node.clientHeight,clipped:node.scrollHeight>node.clientHeight+1}))).toEqual({height:42,clipped:true});
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeLessThan(560);
for(const selector of ['.card-title','.card-content','.card-content > .tags','.card-footer']) {
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1&&node.scrollWidth<=node.clientWidth+1),selector).toBe(true);
}
await prompt.focus();await expect(prompt).toBeFocused();
await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible();
await long.getByRole('button',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
await expect(long.getByRole('button',{name:'Explore history',exact:true})).toBeVisible();
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1);
await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags);
const footer=card.locator('.card-footer > span');
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
}
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i);
const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};}));
for(let i=0;i<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);}
}
@@ -317,23 +444,22 @@ test('row measurement preserves anchor after width invalidation and append',asyn
try {
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await loadShelfPages(page,more,3);
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25));
await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor);
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.prompt-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
{const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');}await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
for(const changedWidth of [false,true]) {
const before=await firstVisibleParent(page);
await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
await page.locator(`article[data-prompt-id="${before.id}"]`).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
expect(await cards(page).count()).toBeLessThan(80);
@@ -347,11 +473,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await loadShelfPages(page,more,3);
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25));
await settleLibrary(page);await page.mouse.move(1100,550);
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
try {
// The same queued-frame wheel sequence is stable without changed measurements.
const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY);
@@ -362,7 +487,7 @@ test('queued row growth preserves newer native wheel movement and correction eve
expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160});
const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';});
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='45px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
const parent=page.locator(`article[data-prompt-id="${before.id}"]`);
const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset;
@@ -378,10 +503,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
// The correction's own native scroll event must not become new user motion.
const corrected=await firstVisibleParent(page);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';});
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='65px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length);
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
@@ -411,8 +536,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId;
} else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);}
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page);
await loadShelfPages(page,more,3);await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0);
const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page);
@@ -422,7 +546,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls);
const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page);
expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1);
await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page);
@@ -436,12 +560,151 @@ test('saved choices retain curved staggered lanes and the full negative immediat
await page.goto(route);await openHistory(page);await selectVariant(page,1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
const selected=details.variants[1].negative_text;
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').evaluate(node=>node.textContent)).toBe(selected);
});
/** Rewrites each shelf item the server sends: run counts, last use and long texts are values the fixture never records. */
async function patchShelf(page,patch) {
await page.route(url=>url.pathname==='/prompt_manager/library',async route=>{
const response=await route.fetch();const body=await response.json();
body.items=body.items.map(item=>({...item,...patch(item)}));
await route.fulfill({response,json:body});
});
}
/** The footer line as seen: everything but its visually hidden rating sentence. */
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
test('card matches the concept and never wraps the category mid-word',async({page,request})=>{
const before=await(await request.get('/prompt_manager/library')).json();
const dog=before.items.find(item=>item.text==='Dog landscape'),cat=before.items.find(item=>item.text==='Cat portrait');
const dual=before.items.find(item=>item.text==='Dual role portrait');
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dog.id],category:'asian'}})).toBeOK();
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
await expect(card).toHaveClass('prompt-card');
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
await expect(card.locator('.card-title > button')).toHaveText('Cat portrait');await expect(card.locator('.card-title > .chip')).toHaveText('Animals');
await expect(card.locator('.card-title > button')).toHaveCSS('font-size','16px');await expect(card.locator('.card-title > button')).toHaveCSS('font-weight','600');
const positive=card.locator('.card-content > .prompt-inspector > section.prompt-section.positive');await expect(positive).toHaveCount(1);
await expect(card.locator('section.prompt-section.negative')).toHaveCount(0);
await expect(positive.getByRole('button',{name:'Copy positive prompt: Cat portrait',exact:true})).toHaveCount(1);
await expect(positive.getByRole('button',{name:'Send positive prompt to ComfyUI: Cat portrait',exact:true})).toHaveCount(1);
await expect(card.locator('.card-meta > *')).toHaveText(['− 4 negative variants','4 runs','3 images']);
await expect(card.locator('.card-meta > .chip.red')).toHaveCount(1);
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
const footer=card.locator('.card-footer');
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
// Unrated and never used: the used label alone; no negatives still reads as a count.
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
await expect(unrated.locator('.card-meta > *')).toHaveText([`− ${dual.variant_count} negative variant${dual.variant_count===1?'':'s'}`,'1 run',`${dual.image_count} image${dual.image_count===1?'':'s'}`]);
for(const [id,text] of [[dual.id,'Uncategorized'],[dog.id,'asian']]) {
const chip=page.locator(`article[data-prompt-id="${id}"] .card-title > .chip`);await expect(chip).toHaveText(text);
expect(await chip.evaluate(node=>{const range=document.createRange();range.selectNodeContents(node);return range.getClientRects().length;}),`${text} at ${width}`).toBe(1);
expect(await page.locator(`article[data-prompt-id="${id}"] .card-title`).evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
}
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
});
test('long prompt is clamped to two lines while copy sends the full text',async({page,context,request})=>{
const before=await(await request.get('/prompt_manager/library')).json();const cat=before.items.find(item=>item.text==='Cat portrait');
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
await patchShelf(page,item=>item.id===cat.id?{text:longPrompt,title:'Long observatory'}:{});
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'positive-1',label:'Card destination',type:'PromptManager',roles:['positive']};
window.cardApplied=[];
window.cardHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Card ComfyUI',adapter:{
listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive}},snapshot:roles}),
apply:roles=>{window.cardApplied.push(roles);return{targetId:target.id,values:roles};},
}});
});
try {
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);
const card=page.locator(`article[data-prompt-id="${cat.id}"]`),text=card.locator('.prompt-text');
await expect(text).toHaveText(longPrompt);
const box=await text.evaluate(node=>{const style=getComputedStyle(node);return {lines:Math.round(node.clientHeight/parseFloat(style.lineHeight)),clipped:node.scrollHeight>node.clientHeight+1,clamp:style.webkitLineClamp,overflow:style.overflowY};});
expect(box).toEqual({lines:2,clipped:true,clamp:'2',overflow:'hidden'});
}
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
await card.getByRole('button',{name:'Copy positive prompt: Long observatory',exact:true}).click();
await expect.poll(()=>page.evaluate(()=>window.copied)).toEqual([longPrompt]);
await card.locator('.card-footer .row-actions > button.send').click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Card ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Card destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.cardApplied)).toEqual([{positive:longPrompt}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();
} finally {await host.evaluate(()=>window.cardHost.dispose());await host.close();}
});
test('title and Explore history are buttons that open history',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
await page.goto(route);const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
await expect(card.locator('a')).toHaveCount(0);
const title=card.locator('.card-title > button');await expect(title).toHaveAttribute('type','button');
await title.click();await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
const explore=page.locator(`article[data-prompt-id="${cat.id}"]`).getByRole('button',{name:'Explore history',exact:true});
await explore.focus();await page.keyboard.press('Enter');await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
// The card's navigation buttons keep the shelf's Home/End and arrow movement between cards.
const first=cards(page).first();await first.locator('.card-title > button').focus();await page.keyboard.press('End');
await expect(cards(page).last().locator('.card-title > button')).toBeFocused();
await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused();
});
test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await patchShelf(page,item=>({text:`${item.text}\n${longPrompt}`}));
// Two columns on a desktop window; the touch project's phone gives the one-column shelf.
{
if(!isMobile)await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
await loadShelfPages(page,more,3);
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+3000));await settleLibrary(page);
const geometry=await page.evaluate(()=>[...document.querySelector('.library-grid').children].map(node=>{
const box=node.getBoundingClientRect();
return {row:node.classList.contains('library-row'),top:box.top,height:box.height,
cards:[...node.querySelectorAll('.prompt-card')].map(card=>({height:card.getBoundingClientRect().height,text:card.querySelector('.prompt-text').clientHeight,clipped:card.querySelector('.prompt-text').scrollHeight>card.querySelector('.prompt-text').clientHeight+1}))};
}));
const rows=geometry.filter(item=>item.row);expect(rows.length).toBeGreaterThan(2);expect(rows.length).toBeLessThan(60);
for(const row of rows) {
expect(row.cards.length).toBeGreaterThan(0);
for(const card of row.cards) {expect(card.text).toBe(42);expect(card.clipped).toBe(true);expect(Math.abs(card.height-row.height)).toBeLessThanOrEqual(1);}
}
// Consecutive mounted rows sit one concept gap apart: every height the layout used is the measured one.
for(let index=1;index<geometry.length;index++) if(geometry[index].row&&geometry[index-1].row) expect(Math.abs(geometry[index].top-(geometry[index-1].top+geometry[index-1].height+18))).toBeLessThanOrEqual(1);
const anchor=await firstVisibleParent(page);await page.evaluate(()=>window.libraryObservation.equal());await settleLibrary(page);await retainedParent(page,anchor);
}
});
@@ -0,0 +1,546 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/saved-views';
const main = page => page.locator('#workspace-main');
const toolbar = page => main(page).locator('search.toolbar');
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filters(?: · \d+)?$/ });
const imageCards = page => page.locator('article[data-image-key]');
const promptCards = page => page.locator('article[data-prompt-id]');
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
// The saved views' own rows, after the Favorites row (D51).
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
const heading = page => main(page).getByRole('heading', { level: 1 });
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const toast = page => page.locator('#workspace-toast');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
async function refine(page, values) {
await filtersButton(page).click();
for (const [label, value] of Object.entries(values)) {
const control = refineDialog(page).getByLabel(label, { exact: true });
if (label === 'Tags') await control.fill(value);
else await control.selectOption(value);
}
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(refineDialog(page)).toHaveCount(0);
}
async function listed(request) {
const response = await request.get(API);
await expect(response).toBeOK();
return (await response.json()).views;
}
async function create(request, data) {
const response = await request.post(API, { data });
expect(response.status()).toBe(201);
return (await response.json()).view;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.savedErrors = [];
page.on('pageerror', error => page.savedErrors.push(error.message));
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
page.on('response', response => {
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
page.savedErrors.push(`${response.status()} ${response.url()}`);
}
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.savedErrors).toEqual([]);
});
test('Save view saves the Images toolbar and Refine filters, and its sidebar row reopens them', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(viewRows(page)).toHaveCount(0);
await search(page).fill('dogs');
await expect(imageCards(page)).toHaveCount(1);
await sortBy(page).selectOption('name');
await refine(page, { Source: '1', Media: 'image' });
await expect(filtersButton(page)).toHaveText('Filters · 2');
const saveButton = toolbar(page).getByRole('button', { name: 'Save view', exact: true });
await saveButton.click();
// The concept's simpleInput: "View name", the search's default name, Cancel and the primary action.
const dialog = saveDialog(page);
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toHaveValue('Search: dogs');
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toBeFocused();
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(saveButton).toBeFocused();
expect(await listed(request)).toEqual([]);
await saveButton.click();
await dialog.getByRole('textbox', { name: 'View name', exact: true }).fill('Dog shots');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const [view] = await listed(request);
expect(view).toMatchObject({ page: 'images', name: 'Dog shots',
state: { ...IMAGE_DEFAULTS, q: 'dogs', sort: 'name', source: '1', media: 'image' } });
// The concept's links() row: the search icon, then the name, and no count.
const row = savedNav(page).getByRole('link', { name: 'Dog shots', exact: true });
await expect(row).toHaveAttribute('href', `#/saved/${view.id}`);
expect(await row.evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE ? `text:${node.textContent}`
: `${node.localName}.${node.getAttribute('class')}`))).toEqual(['svg.icon', 'text:Dog shots']);
await expect(row).not.toHaveAttribute('aria-current', 'page');
// Another page, then the saved row: its page opens with every saved value and the Filters count (D44).
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Prompt library/ }).click();
await expect(promptCards(page).first()).toBeVisible();
await row.click();
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
await expect(main(page).getByRole('heading', { level: 1 })).toBeFocused();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Dog shots');
await expect(page).toHaveTitle('Dog shots · Prompt Manager');
await expect(row).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
await expect(search(page)).toHaveValue('dogs');
await expect(sortBy(page)).toHaveValue('name');
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await filtersButton(page).click();
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('1');
await expect(refineDialog(page).getByLabel('Media', { exact: true })).toHaveValue('image');
await page.keyboard.press('Escape');
// A reload opens the same view from its link.
await page.reload();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
await expect(imageCards(page)).toHaveCount(1);
await expect(savedNav(page).getByRole('link', { name: 'Dog shots', exact: true })).toHaveAttribute('aria-current', 'page');
});
test('an empty name is Saved view, and a Library view reopens the shelf filters', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
const name = saveDialog(page).getByRole('textbox', { name: 'View name', exact: true });
await expect(name).toHaveValue('My workspace view');
await name.fill(' ');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(viewRows(page)).toHaveText(['Saved view']);
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
await page.goto(`${workspace}#/library`);
await expect(promptCards(page)).toHaveCount(2);
await search(page).fill('dog');
await expect(promptCards(page)).toHaveCount(1);
await refine(page, { Tags: 'dogs' });
await expect(filtersButton(page)).toHaveText('Filters · 1');
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await expect(name).toHaveValue('Search: dog');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']);
const library = (await listed(request))[1];
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await savedNav(page).getByRole('link', { name: 'Search: dog', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Search: dog');
await expect(search(page)).toHaveValue('dog');
await expect(filtersButton(page)).toHaveText('Filters · 1');
await expect(promptCards(page)).toHaveCount(1);
// Saved views open the page; one saved view to another re-renders with the other's state.
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Saved view');
await expect(search(page)).toHaveValue('');
await expect(imageCards(page)).toHaveCount(2);
});
test('a saved value the page no longer accepts stays default while the rest applies', async ({ page, request }) => {
const view = await create(request, { page: 'images', name: 'Gone source',
state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'name-desc', source: '7', folder: 'missing/folder' } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Gone source');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
await expect(search(page)).toHaveValue('cat');
await expect(sortBy(page)).toHaveValue('name-desc');
// The missing Source and Folder are dropped, so no refinement is active.
await expect(filtersButton(page)).toHaveText('Filters');
await expect(main(page).locator('.filter-row > span.chip')).toHaveText('All output folders');
// A Source that does exist, with a folder it lists, applies.
const kept = await create(request, { page: 'images', name: 'Input shared',
state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
await page.goto(`${workspace}#/saved/${kept.id}`);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Input shared');
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
test('a view stored under older rules still lists and opens with its usable values (#249 I1)', async ({ page, request }) => {
// No favorites key, a retired key and a sort that is no longer offered, as an older build stored it.
const raw = async data => {
const response = await request.post('/__test__/saved-views/raw', { data });
await expect(response).toBeOK();
return (await response.json()).id;
};
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
]);
// A state that is no JSON object is never listed, and its link behaves like a missing view.
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
expect((await listed(request)).map(view => view.id)).not.toContain(corruptId);
await page.goto(`${workspace}#/saved/${corruptId}`);
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
await page.goto(`${workspace}#/saved/${imagesId}`);
await expect(heading(page)).toHaveText('Old cats');
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
await expect(search(page)).toHaveValue('cat');
await expect(sortBy(page)).toHaveValue('newest');
await expect(imageCards(page)).toHaveCount(1);
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
await expect(heading(page)).toHaveText('Saved view');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
});
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/images`);
await search(page).fill('cat');
await expect(imageCards(page)).toHaveCount(1);
await viewRows(page).filter({ hasText: 'Dogs' }).click();
await expect(heading(page)).toHaveText('Dogs');
await expect(search(page)).toHaveValue('dogs');
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(heading(page)).toHaveText('Images');
await expect(search(page)).toHaveValue('cat');
await expect(imageCards(page)).toHaveCount(1);
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(promptCards(page)).toHaveCount(2);
await search(page).fill('dog');
await expect(promptCards(page)).toHaveCount(1);
await page.goto(`${workspace}#/saved/${cats.id}`);
await expect(heading(page)).toHaveText('Cats');
await expect(search(page)).toHaveValue('cat');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(heading(page)).toHaveText('Prompt library');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
expect(dogs.id).not.toBe(cats.id);
});
test('a toolbar change while sources load keeps the saved Source and Folder (#249 M3)', async ({ page, request }) => {
const view = await create(request, { page: 'images', name: 'Input shared', state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
let release;
const held = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/subfolders?**', async route => { await held; await route.fallback(); });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Input shared');
await sortBy(page).selectOption('name');
await expect(imageCards(page)).toHaveCount(0);
release();
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(sortBy(page)).toHaveValue('name');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let releaseList = () => {};
const holdList = async () => {
const held = new Promise(resolve => { releaseList = resolve; });
await page.unroute(`**${API}`);
await page.route(`**${API}`, async route => { if (route.request().method() === 'GET') await held; await route.fallback(); });
};
// Leaving while the shell's first read is pending: the view never mounts over the page left to.
await holdList();
await page.goto(`${workspace}#/saved/${view.id}`);
await page.evaluate(() => { window.location.hash = '#/tags'; });
await expect(heading(page)).toHaveText('Tags & categories');
releaseList();
await expect(viewRows(page)).toHaveText(['Cats']);
await expect(heading(page)).toHaveText('Tags & categories');
// An unknown id re-reads the list once; leaving during that read keeps the page left to.
await holdList();
await page.evaluate(() => { window.location.hash = '#/saved/999'; });
await page.evaluate(() => { window.location.hash = '#/library'; });
await expect(heading(page)).toHaveText('Prompt library');
releaseList();
await expect(viewRows(page)).toHaveText(['Cats']);
await expect(page).toHaveURL(/#\/library$/);
await expect(heading(page)).toHaveText('Prompt library');
await page.unroute(`**${API}`);
// A rename that lands after the view was left does not retitle the next page.
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${view.id}`, async route => { await renameHeld; await route.fallback(); });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
await main(page).locator('.heading-actions > button').first().click();
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(heading(page)).toHaveText('Images');
releaseRename();
await expect(viewRows(page)).toHaveText(['Kittens']);
await expect(heading(page)).toHaveText('Images');
await expect(page).toHaveTitle('Images · Prompt Manager');
});
test('an unknown saved view opens Images, and a failed listing offers Retry', async ({ page, request }) => {
await page.goto(`${workspace}#/saved/999`);
await expect(page).toHaveURL(/#\/images$/);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let failing = true;
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
: route.fallback());
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(viewRows(page)).toHaveCount(0);
failing = false;
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
await expect(search(page)).toHaveValue('cat');
await expect(promptCards(page)).toHaveCount(1);
await expect(viewRows(page)).toHaveText(['Cats']);
});
test('a failed save says so and adds nothing', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await page.route(`**${API}`, route => route.request().method() === 'POST'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
: route.fallback());
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
});
async function star(page, key) {
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
}
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
let release;
const held = new Promise(resolve => { release = resolve; });
// Hidden until its read settles: never a placeholder "0".
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
await expect(favoritesCount(page)).toHaveCount(0);
release();
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
await expect(favoritesCount(page)).toHaveText('0');
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Added to favorites.');
await expect(favoritesCount(page)).toHaveText('1');
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
});
await favoritesRow(page).click();
await expect(page).toHaveURL(/#\/favorites$/);
await expect(heading(page)).toHaveText('Favorites');
await expect(heading(page)).toBeFocused();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
await expect(page).toHaveTitle('Favorites · Prompt Manager');
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
expect(listings.at(-1).get('favorites')).toBe('1');
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Removed from favorites.');
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
await expect(favoritesCount(page)).toHaveText('0');
await star(page, '1:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
// Favorites never replaces the Images return state: Images still lists everything.
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
await expect(heading(page)).toHaveText('Images');
await expect(imageCards(page)).toHaveCount(2);
await page.goto(`${workspace}#/favorites`);
await expect(heading(page)).toHaveText('Favorites');
await expect(imageCards(page)).toHaveCount(1);
});
test('a failed Favorites count shows no badge', async ({ page }) => {
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/images`);
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
await expect(favoritesRow(page)).toBeVisible();
await expect(favoritesCount(page)).toHaveCount(0);
// The refused count read is this test's own; nothing else failed.
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
});
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await star(page, '0:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
await page.goto(`${workspace}#/favorites`);
await expect(imageCards(page)).toHaveCount(1);
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const [view] = await listed(request);
expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
await expect(viewRows(page)).toHaveText(['Best']);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Best');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// Plain pages have no Edit.
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Dogs');
// D51: Edit leads the heading's actions of an open view.
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
await edit.click();
const dialog = editDialog(page);
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
await expect(name).toHaveValue('Dogs');
await expect(name).toBeFocused();
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(edit).toBeFocused();
// An empty name keeps the dialog open on its field.
await edit.click();
await name.fill(' ');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(name).toBeFocused();
await name.fill('Good dogs');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view renamed.');
await expect(viewRows(page)).toHaveText(['Good dogs']);
await expect(heading(page)).toHaveText('Good dogs');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
await expect(edit).toBeFocused();
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
// The rename kept the view's page state.
await expect(search(page)).toHaveValue('dogs');
await expect(imageCards(page)).toHaveCount(1);
// Delete… asks first; its Cancel deletes nothing.
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
const confirm = deleteDialog(page);
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(confirm).toHaveCount(0);
expect(await listed(request)).toHaveLength(1);
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
// The dead link left no history entry.
await page.goBack();
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
});
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
let failing = true;
await page.route(`**${API}/${view.id}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
: route.fallback());
await edit.click();
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
await expect(heading(page)).toHaveText('Cats');
await expect(viewRows(page)).toHaveText(['Cats']);
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be deleted. Try again.');
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
expect(await listed(request)).toHaveLength(1);
failing = false;
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/library$/);
await expect(heading(page)).toHaveText('Prompt library');
await expect(viewRows(page)).toHaveCount(0);
});
+123 -94
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', '.detail-panel', '.prompt-heading', '.library-grid', '.prompt-card', '.prompt-card .card-art', '.prompt-card .card-content'].map(selector => {
const node = document.querySelector(selector);
if (!node) return [selector, null];
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
@@ -92,25 +92,26 @@ 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 });
const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title;
const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, exact: true });
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
const actual = await historyGeometry(page, true);
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
const title = await page.locator('#workspace-main > .heading h1').textContent();
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name: `${name}: ${title}`, exact: true })).toBeVisible();
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
await expect(page.locator('.workspace-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 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');
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-history .history-gallery'], ['chronology', '.recorded-history']]) {
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
}
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
@@ -122,16 +123,20 @@ 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();
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible();
await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).toBeVisible();
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
await currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click();
// The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused.
const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true });
await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1);
if (await copyFallback.isVisible()) { await page.keyboard.press('Escape'); await expect(copyFallback).toBeHidden(); }
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.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 +202,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,15 +241,16 @@ 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);
await text.focus(); await expect(text).toBeFocused();
// #249 D23: the card clamps the prompt to two lines; the card content itself never scrolls or overflows.
expect(await text.evaluate(node => {
const scroller = node.closest('.library-card-prompts');
return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1;
const content = node.closest('.card-content');
return content.scrollHeight <= content.clientHeight + 1 && content.scrollWidth <= content.clientWidth + 1 && node.scrollHeight > node.clientHeight + 1;
})).toBe(true);
await noOverflow(page);
await page.evaluate(() => { document.documentElement.style.zoom = ''; });
@@ -257,17 +263,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 +283,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 +296,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 +347,31 @@ 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');
await expect(page.locator('.image-details h2')).toHaveText(filename);
const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true });
// 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('.detail-panel .panel-heading h2')).toHaveText(filename);
// D19: the card image, not the panel, opens the viewer.
const fullPreview = longCard.locator('.image-open');
await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
await expect(page.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 +393,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 +420,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);
@@ -442,7 +460,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2);
await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/);
await expect(selected.locator('.image-open')).toBeFocused();
await expect(page.locator('.image-details h2')).toHaveText(filename);
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
const mutations = await grid.evaluate(async node => {
let changes = 0;
const observer = new MutationObserver(records => { changes += records.length; });
@@ -454,10 +472,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 +497,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 +509,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 +537,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 page.getByRole('button',{name:'Explore history',exact:true}).first().click();
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('.library-history > div > .intro')).toHaveCount(1);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await expect(page.locator('.workspace-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 +556,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 +572,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,53 +600,57 @@ 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 expect(page.locator('.image-details h2')).toHaveText('study-01.png');
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('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
for (const layout of ['Grid','List','Masonry']) {
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
if(layout==='List') await expect(page.locator('.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('.detail-panel .panel-heading h2')).toHaveText(longName);
await orderedMedia(page);
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
}
}
await page.evaluate(()=>{location.hash='#/overview';});
await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
await expect(page.locator('.media-card,.detail-panel,.viewer-container,dialog[open]')).toHaveCount(0);
});
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
await request.post('/__test__/reset',{data:{workspace_visual:true}});
for (const width of [1600,390]) {
await page.setViewportSize({width,height:width===1600?1100:844});await page.goto(`${workspace}#/library`);
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('h1')).not.toHaveText('Prompt library');
const title=await page.locator('h1').textContent();expect(title).not.toBe('Images');
// History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery.
// History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked
// to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26).
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await expect(listing.locator('article[data-image-key]').first()).toBeVisible();
for (const layout of ['List','Masonry','Grid']) {
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
await listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
await expect(page.locator('h1')).toHaveText(title);
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
}
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0);
}
});
+76 -20
View File
@@ -24,13 +24,18 @@ async function expectLibrary(page,{tags='',category='',count}) {
await expect(page).toHaveURL(/#\/library$/);
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
const select=page.getByRole('combobox',{name:'Category',exact:true});
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('');
const select=page.getByRole('combobox',{name:'Filter by category',exact:true});
await expect(select).toHaveValue(category);
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(new RegExp(`^${count} positive prompts? · \\d+ negative variants?$`));
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
// Browse sets exactly this Tags filter, which lives in "Refine this view" (#249 D14); Filters counts it (D44).
const filters=page.locator('#workspace-main search.toolbar').getByRole('button',{name:tags?'Filters · 1':'Filters',exact:true});
await expect(filters).toBeVisible();await filters.click();
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
await expect(refine.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
await refine.getByRole('button',{name:'Close dialog',exact:true}).click();await expect(refine).toHaveCount(0);
}
let manifest;
@@ -43,8 +48,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);
@@ -85,11 +90,33 @@ test('prompt counts equal Library Browse results',async({page})=>{
}
});
// #249 D44: a Browse-set Tags filter is visible on the shelf as "Filters · 1", and Clear filters returns the full shelf.
test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})=>{
await page.goto('/prompt_manager/workspace#/library');
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
const toolbar=page.locator('#workspace-main search.toolbar');
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await page.goto(route);
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
await expectLibrary(page,{tags:'cinematic',count:2});
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
await expect(shelf).toHaveCount(total);
// The cleared filter stays cleared on the next in-app visit (the shelf remembers its filters).
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
});
test('category Browse sets only the category constraint',async({page})=>{
await page.goto('/prompt_manager/workspace#/library');
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',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 +154,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 +177,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 +189,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 +204,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 +222,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});
});
@@ -399,8 +428,8 @@ test('delete tag confirms real impact and removes only the vocabulary name',asyn
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
await page.goto('/prompt_manager/workspace#/library');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
});
test('cancel and escape write nothing and restore focus',async({page,request})=>{
@@ -482,8 +511,9 @@ 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});
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
// 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] :is(.card-title, .tags) > .chip').filter({hasText:new RegExp(`^${name}$`)});
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
async function heldRename(page) {
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
@@ -666,10 +696,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 +821,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/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);
}
});
+531 -43
View File
@@ -4,21 +4,30 @@ 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'],
['library', 'Prompt library', '/prompt_manager/admin'],
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
['images', 'Images', null],
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
['library', 'Prompt library', null],
['tags', 'Tags & categories', null],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
['favorites', 'Favorites', null],
];
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 +75,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 +84,26 @@ 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 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
await page.goto(`${workspace}#/overview`);
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
await expect(await request.get('/prompt_manager/admin')).toBeOK();
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
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 +111,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 +119,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 +288,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 +313,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 +343,387 @@ 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-label',
'nav#saved-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
await expect(page.locator('#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(3);
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');
});
+482
View File
@@ -0,0 +1,482 @@
"""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",
"saved_views",
"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 = []
# The concept lists its prompts most recent first (renderLibrary under Recently
# added keeps the array order), so they are created last to first.
for prompt in reversed(CONCEPT_PROMPTS):
first_name, first_text = prompt.negatives[0]
receipt = db.create_library_recipe(
prompt.positive,
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.insert(
0, {"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 = [], []
slots = [
(round_index, prompt_index, prompt)
for round_index in range(rounds)
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS)
if round_index * len(CONCEPT_PROMPTS) + prompt_index < total
]
# Linked and recorded oldest first, as real runs arrive, so link and run ids
# grow with time and every newest-first read (by id or by mtime) lists the
# concept's order (app.js:180-239). The manifest keeps the concept's order.
for round_index, prompt_index, prompt in reversed(slots):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
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"})
# Real pair counting: each image is one run of its pair, as the
# concept's "<name> · N runs" counts its images (app.js a598, D26).
db.record_prompt_pair_use(pair_id)
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"],
}
)
images.reverse()
runs.reverse()
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,
}
+10 -1
View File
@@ -479,8 +479,14 @@ def seed_workspace_history(
canonical_positive, canonical_negative, title="Quality baseline"
)
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
clean_pair = api.db.save_prompt_pair(
canonical_positive, "flat light", title="Clean geometry"
)
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
# Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only
# the baseline has a recorded execution, so Clean geometry opens Repeat run.
api.db.record_prompt_pair_use(canonical_pair, 2)
api.db.record_prompt_pair_use(clean_pair, 2)
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
path = folder / name
_write_art(path, number, canonical_positive, canonical_negative)
@@ -500,6 +506,8 @@ def seed_workspace_history(
os.utime(path, (stamp, stamp))
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
if number:
api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"})
canonical_run = run(
(
RecipeContext(
@@ -529,6 +537,7 @@ def seed_workspace_history(
"detail_image_id": current,
"canonical_id": canonical_id,
"canonical_pair": canonical_pair,
"clean_pair": clean_pair,
"canonical_run": canonical_run,
"canonical_positive": canonical_positive,
"canonical_negative": canonical_negative,
@@ -26,6 +26,8 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -112,6 +112,8 @@ 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 (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -79,6 +79,8 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
+2 -2
View File
@@ -237,13 +237,13 @@ 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: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('p.prompt-text').evaluate(node => node.textContent)).toBe(value);
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
try {
await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
+7 -7
View File
@@ -22,13 +22,13 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace#/library');
await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive);
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive);
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
await history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click();
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
// The pair send lives in the history heading, as in the approved concept.
await workspace.locator('.workspace-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,14 +60,14 @@ 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);
await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1));
await expect(workspace.locator('#workspace-main h1')).toHaveText(title);
const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true});
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('No negative prompt (explicitly empty)');
const evidence=async()=>{
const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json();
const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json();
@@ -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);
+10 -8
View File
@@ -41,15 +41,17 @@ 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();
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
const inspector=workspace.getByRole('complementary',{name:'Selected image details',exact:true});
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
const viewer=workspace.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
await expect(viewer.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(viewer.locator('pre code').first()).toContainText('9007199254740993');
await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true');
@@ -57,12 +59,12 @@ test('native image inspector previews and sends a pair into real visible widgets
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
await expect(viewer).toBeVisible();await expect(viewer.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
}
expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0);
await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};});
await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click();
await viewer.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);
+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');
File diff suppressed because one or more lines are too long
+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(),
}
+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);
+16
View File
@@ -224,6 +224,22 @@ test("facts use resolution.sampler_id for detailer-stage nodes", () => {
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("a shared pair resolved across sampler branches merges facts per field (#249)", () => {
// The server resolves identical branches with no sampler named: settings the
// branches agree on are known, those they differ on (a LoRA per epoch) are ambiguous.
const shared = { resolution: { state: "resolved", sampler_id: null, reason: "Every sampler branch uses the same prompt pair" } };
const grid = factGraph();
grid.lora = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "epoch_10.safetensors" } };
grid.other = structuredClone(grid.s); grid.other.inputs.model = ["lora", 0];
assert.deepEqual(statusValues(factsFor(grid, shared)), {
seed: ["known", "9007199254740993"], steps: ["known", "28"], cfg: ["known", "6.5000000000000000001"],
sampler: ["known", "dpmpp_2m"], scheduler: ["known", "karras"], model: ["ambiguous", null],
});
assert.ok(Object.values(factsFor(grid, shared)).every(fact => fact.sampler_id === null));
// One listed sampler keeps reading that sampler alone.
assert.equal(factsFor(factGraph(), shared).model.value, "model.safetensors");
});
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.
+1 -1
View File
@@ -30,7 +30,7 @@ test("facts guider providers are guiders in the semantics table", () => {
});
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.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams", "legacyNegativeSource"]);
assert.ok(Object.isFrozen(metadata._factsTables));
});
+135 -15
View File
@@ -5,7 +5,7 @@ import * as module from '../../web/js/workspace/prompt-inspector.mjs';
function dom() {
class Element {
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};}
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};this.className='';}
append(...children){for(const child of children){child.parent=this;this.children.push(child);}}
replaceChildren(...children){this.children=[];this.append(...children);}
setAttribute(key,value){this.attributes[key]=value;}
@@ -13,9 +13,9 @@ function dom() {
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
click(target){this.listeners.get('click')?.({target});}
}
const previous=global.document;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
return {Element,walk,restore:()=>{global.document=previous;}};
return {Element,walk,toast,restore:()=>{global.document=previous;}};
}
const record=text=>({status:'resolved',text,source:'library'});
test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{
@@ -27,7 +27,7 @@ test('shared prompt blocks send complete selected library values and explicit em
view.update({positive:record('Complete positive'),negative:record('')});
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]);
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2);
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,0,'no per-block source line');
view.dispose();container.click(pair);assert.equal(sent.length,1);
}finally{scope.dispose();f.restore();}
});
@@ -51,19 +51,19 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
}finally{scope.dispose();f.restore();}
});
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
test('frozen fields keep null, empty and whitespace actions distinct without a source line',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
assert.equal(f.walk(container,node=>node.textContent?.includes('runtime_return')).length,0,'no per-block source line');
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
assert.equal(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length,0);
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent===' \n\t ').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
assert.deepEqual(sent[1],{negative:' \n\t '});
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
@@ -71,19 +71,19 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
}finally{scope.dispose();f.restore();}
});
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
test('a zeroed conditioning negative shows as an explicit empty negative without a source line',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,0);
assert.equal(f.walk(container,node=>node.className==='prompt-text'&&node.textContent==='No negative prompt (explicitly empty)').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
}finally{scope.dispose();f.restore();}
});
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent);
test('unsupported node status names the class',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
@@ -101,7 +101,7 @@ test('runtime and composite statuses',()=>{
assert.deepEqual(statusLines(f,container),[runtime,runtime]);
view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
assert.deepEqual(statusLines(f,container),[composite]);
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent==='kept').length,1);
}finally{scope.dispose();f.restore();}
});
test('limit status',()=>{
@@ -136,10 +136,130 @@ test('a context label names nested prompt regions and actions apart from the fro
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt to ComfyUI','Copy current file negative prompt','Send current file negative prompt to ComfyUI']) assert.ok(labels.includes(name),name);
assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
assert.deepEqual(f.walk(container,node=>node.className==='role-label').map(node=>node.textContent),['+ Positive prompt','\u2212 Negative prompt']);
const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
}finally{scope.dispose();f.restore();}
});
test('a title names each role action after its prompt as the concept does',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',prompts:{positive:record('p'),negative:record('n')}});
const actions=f.walk(container,node=>node.tagName==='button').map(node=>node.attributes['aria-label']);
assert.deepEqual(actions,['Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory',
'Copy negative prompt: Alpine observatory','Send negative prompt to ComfyUI: Alpine observatory','Send prompt pair']);
const nested=new f.Element('div');
module.mountPromptBlocks(nested,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',title:'Alpine observatory',includePairAction:false,prompts:{positive:record('p')}});
assert.deepEqual(f.walk(nested,node=>node.tagName==='button').map(node=>node.attributes['aria-label']),
['Copy current file positive prompt: Alpine observatory','Send current file positive prompt to ComfyUI: Alpine observatory']);
}finally{scope.dispose();f.restore();}
});
test('a title names each action after its prompt (concept "Copy positive prompt: <title>") while regions keep their names',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',roles:['positive'],prompts:{positive:record('p')}});
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
assert.deepEqual(labels,['Positive prompt','Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory']);
}finally{scope.dispose();f.restore();}
});
test('setTitle renames the actions in place and keeps the last prompts and state',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Old name',roles:['positive'],prompts:{positive:record('p')}});
view.setState({prompts:{positive:record('kept')},actionsEnabled:false});
const labels=()=>f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
const rendered=container.children[0];view.setTitle('Old name');assert.equal(container.children[0],rendered,'an unchanged title does not re-render');
view.setTitle('New name');
assert.deepEqual(labels(),['Positive prompt','Copy positive prompt: New name','Send positive prompt to ComfyUI: New name']);
assert.ok(f.walk(container,node=>node.textContent==='kept').length);
assert.ok(f.walk(container,node=>node.dataset.copyRole==='positive').every(node=>node.disabled===true));
view.setTitle('');assert.deepEqual(labels(),['Positive prompt','Copy positive prompt','Send positive prompt to ComfyUI']);
scope.dispose();view.setTitle('After dispose');
}finally{scope.dispose();f.restore();}
});
const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}),
...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})});
test('prompt block emits concept markup without a source line',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,includePairAction:false,sender:{open:()=>{}},prompts:{positive:{...record('a lighthouse'),evidence:{provenance:'recorded',source:'runtime_return'}},negative:record('blurry')}});
const section=(role,label,text)=>({tag:'section',className:`prompt-section ${role}`,label:`${role[0].toUpperCase()}${role.slice(1)} prompt`,children:[
{tag:'div',className:'prompt-heading',children:[{tag:'span',className:'role-label',text:label},{tag:'div',className:'prompt-actions',children:[
{tag:'button',className:'icon-btn',label:`Copy ${role} prompt`,children:[]},{tag:'button',className:'send icon-btn',label:`Send ${role} prompt to ComfyUI`,children:[]}]}]},
{tag:'p',className:'prompt-text',text}]});
assert.deepEqual(container.children.map(shape),[section('positive','+ Positive prompt','a lighthouse'),section('negative','\u2212 Negative prompt','blurry')]);
for(const button of f.walk(container,node=>node.tagName==='button')) assert.equal(button.children.filter(child=>child.tagName==='svg').length,1,'one icon per action');
}finally{scope.dispose();f.restore();}
});
test('empty negative uses the concept copy and still sends an explicit clear',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('positive'),negative:record('')}});
const negative=container.children[1];
assert.equal(negative.children[1].className,'prompt-text');assert.equal(negative.children[1].textContent,'No negative prompt (explicitly empty)');
container.click(f.walk(negative,node=>node.dataset.sendRole==='negative')[0]);
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
assert.deepEqual(sent,[{negative:''},{positive:'positive',negative:''}]);
}finally{scope.dispose();f.restore();}
});
test('unknown roles never offer copy or send',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,roles:['positive','negative','style'],sender:{open:roles=>sent.push(roles)},
prompts:{positive:{status:'absent'},negative:{status:'ambiguous',text:'candidate'},style:record('not a role')}});
assert.deepEqual(container.children.map(node=>node.className),['prompt-section positive','prompt-section negative'],'an unknown role name renders nothing');
assert.deepEqual(statusLines(f,container),['Not recorded in this file.','Multiple possible prompts. Source could not be determined.']);
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined||node.className==='prompt-actions').length,0);
const forged=new f.Element('button');forged.dataset.sendRole='style';container.append(forged);container.click(forged);
assert.equal(sent.length,0,'a forged action for an unknown role sends nothing');
}finally{scope.dispose();f.restore();}
});
test('copy shows the shared toast "Prompt copied." with the exact role text',async()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),copied=[];
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async text=>{copied.push(text);}}}});
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record(' exact\ntext '),negative:record('')}});
await container.listeners.get('click')({target:f.walk(container,node=>node.dataset.copyRole==='positive')[0]});
assert.deepEqual(copied,[' exact\ntext ']);assert.equal(f.toast.textContent,'Prompt copied.');
assert.equal(f.walk(container,node=>node.attributes.role==='status').length,0,'no per-block copy status line');
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
});
/** Runs one copy click with `clipboard` as `navigator.clipboard`, recording what the fallback seam receives. */
async function copyWithClipboard(clipboard,{detach=false}={}) {
const f=dom(),scope=createScope(),container=new f.Element('div'),fallbacks=[];
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
Object.defineProperty(globalThis,'navigator',{configurable:true,value:clipboard===undefined?{}:{clipboard}});
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('exact text'),negative:record('')},
copyFallback:(text,options)=>fallbacks.push({text,...options})});
const copy=f.walk(container,node=>node.dataset.copyRole==='positive')[0];
let current=copy;
if(detach) {
// The blocks re-render while the clipboard is pending: the clicked button is detached, its successor is not.
copy.isConnected=false;container.querySelector=selector=>selector==='[data-copy-role="positive"]'?current:null;
}
const pending=container.listeners.get('click')({target:copy});
if(detach) {view.update({positive:record('exact text'),negative:record('')});current=f.walk(container,node=>node.dataset.copyRole==='positive')[0];}
await pending;
return {fallbacks,toast:f.toast.textContent,copy,current,scope};
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
}
test('copy without a clipboard opens the select-to-copy fallback and shows no toast',async()=>{
const {fallbacks,toast,copy,scope}=await copyWithClipboard(undefined);
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.equal(fallbacks[0].trigger,copy);assert.ok(fallbacks[0].scope);
assert.notEqual(toast,'Prompt copied.');assert.equal(scope.signal.aborted,true);
});
test('a refused clipboard write opens the fallback instead of the toast',async()=>{
const {fallbacks,toast}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}});
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.notEqual(toast,'Prompt copied.');
});
test('a fallback after a re-render returns focus to the same-role copy button, not the detached one',async()=>{
const {fallbacks,copy,current}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}},{detach:true});
assert.equal(fallbacks.length,1);assert.notEqual(current,copy);assert.equal(fallbacks[0].trigger,current);
});
@@ -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 }));
+22 -1
View File
@@ -5,7 +5,7 @@ const modulePath='../../web/js/workspace/file-inspector.mjs';
const load=()=>import(modulePath);
const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
function record() {
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{category_scope:'pair',tags_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
}
test('metadata export retains literal raw and scoped evidence',async()=>{
@@ -65,3 +65,24 @@ test('limited workflow record exports its representation and disables workflow d
assert.equal(downloads.workflow.available,false);assert.equal(downloads.workflow.bodyText,null);
assert.equal(downloads.workflow.reason,'Workflow exceeds inspection limits. Download the original file to inspect it externally.');
});
test('Show in folder needs the full listing identity (D38)',async()=>{
const {revealTarget}=await load(),scope='a'.repeat(64);
assert.deepEqual(revealTarget(record().identity),{root_index:2,root_scope:scope,path:'a.png'});
for(const identity of [
{kind:'rooted-file',root_index:2,path:'a.png',root_scope:null},
{kind:'rooted-file',root_index:'2',path:'a.png',root_scope:scope},
{kind:'rooted-file',root_index:2,path:'',root_scope:scope},
{kind:'reported-location',path:'folder/a.png',current_scope:scope},
null,
])assert.equal(revealTarget(identity),null);
});
test('Show in folder outcomes use the D38 toasts',async()=>{
const {revealOutcome}=await load();
assert.equal(revealOutcome(null),'Opened the folder on this computer.');
assert.equal(revealOutcome({status:403,code:'not-local'}),'Show in folder works only in a browser on the computer running ComfyUI.');
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on this computer.');
assert.equal(revealOutcome({status:429,code:'busy'}),'The folder is already opening.');
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
});
+1 -1
View File
@@ -8,7 +8,7 @@ const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],category_scope:null,tags_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
+14
View File
@@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async()
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
}
});
test('added terms split, trim and exact-diff like the concept',()=>{
// The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly.
assert.equal(typeof m.addedTerms,'function','addedTerms is exported');
assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']);
assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']);
assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']);
// Duplicates in the selected negative stay, as the concept lists every added term in order.
assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']);
// Case and inner whitespace are exact.
assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']);
assert.deepEqual(m.addedTerms('same, text','text, same'),[]);
assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError);
});
@@ -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 is linked to a saved prompt, so there is no prompt category to set.");
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.');
});
+169
View File
@@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported
await stream.reset({root:'all',media:'all',sort:'name-desc'});
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
assert.equal(query.has('favorites'),false);
});
test('the Favorites page asks the listing for favorites only (#249 D51)', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({favorites:true,q:'fog'});
assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog');
await stream.reset({favorites:false});
assert.equal(query.has('favorites'),false);
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
@@ -183,3 +194,161 @@ 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 Filters count names each active Refine filter once (D44)', async () => {
const {activeRefinements}=await source;
assert.equal(activeRefinements({root:'all',subfolder:'',media:'all',tags:[]}),0);
assert.equal(activeRefinements({root:'',subfolder:'',media:'all',tags:[]}),0);
assert.equal(activeRefinements({root:'1',subfolder:'',media:'all',tags:[]}),1);
assert.equal(activeRefinements({root:'all',subfolder:'bulk',media:'video',tags:['a','b']}),3);
assert.equal(activeRefinements({root:'0',subfolder:'bulk',media:'image',tags:['a']}),4);
});
test('the card title is the confirmed linked title, else the filename (D10)', async () => {
const {cardTitle}=await source;
const linked=(status,title)=>({filename:'alpine_00001.png',linked:{status,positive_id:1,title,title_source:'title',category:null,rating:null}});
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');
});
+148 -1
View File
@@ -27,7 +27,7 @@ function envelope(entry = image()) {
workflow_graph: { status: 'absent', value: null }, parameters: null,
},
resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' },
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' },
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], category_scope: 'pair', tags_scope: 'pair' },
};
}
function pendingApi() {
@@ -276,6 +276,10 @@ test('record rejects root path media and invalid versioned fields with retryable
data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; },
data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; },
data => { data.resolution.sampler_id = {}; },
data => { data.library.tags_scope = 'recipe'; },
data => { data.library.category_scope = 'recipe'; },
data => { delete data.library.category_scope; },
data => { delete data.library.tags_scope; },
]) {
const data = envelope(); mutate(data);
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired);
@@ -534,6 +538,136 @@ test('a definite server prompt is kept even when the legacy parser reads it diff
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
});
// #249: a LoRA compare grid. Every sampler's negative is a ConditioningZeroOut of the
// positive encode; the legacy parser follows the zero-out back to the positive text.
function zeroedGridGraph(samplers = 3) {
const graph = {
1: { class_type: 'UNETLoader', inputs: { unet_name: 'base.safetensors', weight_dtype: 'default' } },
2: { class_type: 'CLIPLoader', inputs: { clip_name: 'te.safetensors', type: 'stable_diffusion' } },
4: { class_type: 'CLIPTextEncode', inputs: { text: 'a woman holding a coffee cup', clip: ['2', 0] } },
5: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['4', 0] } },
6: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } },
};
for (let index = 0; index < samplers; index++) {
graph[`1${index}`] = { class_type: 'LoraLoaderModelOnly', inputs: { lora_name: `epoch_${index}.safetensors`, strength_model: 1, model: ['1', 0] } };
graph[`2${index}`] = { class_type: 'KSampler', inputs: { seed: 42, steps: 8, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1,
model: [`1${index}`, 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] } };
}
return graph;
}
function undecided(graph) {
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: graph };
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
test('a zeroed negative is never filled with the positive text from the legacy parse (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The legacy pages keep their reading: the zero-out passes the positive through.
assert.equal(parser.extractGenerationParams({ prompt: zeroedGridGraph() }).negativePrompt, 'a woman holding a coffee cup');
const record = undecided(zeroedGridGraph());
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// Behind an API-graph Reroute the legacy parse reads no negative, so there is nothing
// to correct: the server's answer stays.
const rerouted = zeroedGridGraph(1);
rerouted[7] = { class_type: 'Reroute', inputs: { value: ['5', 0] } };
rerouted[20].inputs.negative = ['7', 0];
assert.equal(parser.legacyNegativeSource({ prompt: rerouted }), null);
assert.deepEqual(undecided(rerouted).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
test('the zero-out check follows the sampler the legacy parse read (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
const zeroed = { status: 'resolved', text: '', source: 'zeroed-conditioning' };
// The legacy parse reads the first sampler (20): its negative is the zero-out.
const mixed = zeroedGridGraph(2);
mixed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
mixed[21].inputs.negative = ['8', 0];
assert.deepEqual(undecided(mixed).prompts.negative, zeroed);
// Reversed: the sampler it reads encodes "blurry", which it showed before and still does.
const reversed = zeroedGridGraph(2);
reversed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
reversed[20].inputs.negative = ['8', 0];
assert.equal(parser.extractGenerationParams({ prompt: reversed }).negativePrompt, 'blurry');
assert.deepEqual(undecided(reversed).prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
// The positive fills from the legacy parse in both cases.
assert.equal(undecided(mixed).prompts.positive.text, 'a woman holding a coffee cup');
assert.equal(undecided(reversed).prompts.positive.text, 'a woman holding a coffee cup');
});
test('a zero-out mixed into the read negative leaves it as the server left it (#249)', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
// A zero-out combined with another conditioning is not an empty negative.
const combined = zeroedGridGraph(1);
combined[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
combined[9] = { class_type: 'ConditioningCombine', inputs: { conditioning_1: ['5', 0], conditioning_2: ['8', 0] } };
combined[20].inputs.negative = ['9', 0];
assert.deepEqual(undecided(combined).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
function workflowOnly(workflow) {
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'unresolved', text: null, source: null }, negative: { status: 'unresolved', text: null, source: null } };
Object.assign(data.metadata, { raw: { workflow: JSON.stringify(workflow) }, record_formats: { workflow: 'reserialized-json' },
prompt_graph: { status: 'absent', value: null }, workflow_graph: { status: 'parsed', value: workflow } });
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
// A UI workflow: encode 4 -> zero-out 5 -> sampler 3's negative, encode 4 -> positive.
function zeroedWorkflow({ zeroMode = 0, extraNodes = [], extraLinks = [], negativeLink = 3 } = {}) {
return { nodes: [
{ id: 4, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [1, 2] }], widgets_values: ['a woman holding a coffee cup'] },
{ id: 5, type: 'ConditioningZeroOut', mode: zeroMode, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 1 }], outputs: [{ links: [3] }] },
{ id: 1, type: 'UNETLoader', inputs: [], outputs: [{ links: [4] }], widgets_values: ['base.safetensors', 'default'] },
{ id: 3, type: 'KSampler', inputs: [{ name: 'model', link: 4 }, { name: 'positive', link: 2 }, { name: 'negative', link: negativeLink }], widgets_values: [42, 'fixed', 8, 1, 'euler', 'simple', 1] },
...extraNodes,
], links: [[1, 4, 0, 5, 0, 'CONDITIONING'], [2, 4, 0, 3, 1, 'CONDITIONING'], [3, 5, 0, 3, 2, 'CONDITIONING'], [4, 1, 0, 3, 0, 'MODEL'], ...extraLinks] };
}
test('workflow-only files follow the legacy reading of their zero-out (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// A connected zero-out on the read sampler's negative is the zeroed empty negative.
const direct = zeroedWorkflow();
assert.equal(parser.extractGenerationParams({ workflow: direct }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(direct).prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// A bypassed zero-out (mode 4) passes its conditioning through: the legacy reading is right.
const bypassed = workflowOnly(zeroedWorkflow({ zeroMode: 4 }));
assert.deepEqual(bypassed.prompts.negative, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
// An unconnected zero-out beside a text negative changes nothing.
const unconnected = workflowOnly(zeroedWorkflow({ negativeLink: 6,
extraNodes: [{ id: 6, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [6] }], widgets_values: ['blurry'] }],
extraLinks: [[6, 6, 0, 3, 2, 'CONDITIONING']] }));
assert.deepEqual(unconnected.prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
});
test('a negative read through a subgraph holding a zero-out is not filled (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The sampler's negative comes from a subgraph instance whose definition zeroes it.
const definition = 'aaaaaaaa-0000-4000-8000-000000000001';
const workflow = zeroedWorkflow({ negativeLink: 7,
extraNodes: [{ id: 9, type: definition, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 8 }], outputs: [{ links: [7] }] }],
extraLinks: [[7, 9, 0, 3, 2, 'CONDITIONING'], [8, 4, 0, 9, 0, 'CONDITIONING']] });
workflow.definitions = { subgraphs: [{ id: definition, nodes: [{ id: 1, type: 'ConditioningZeroOut', inputs: [], outputs: [] }], links: [] }] };
assert.equal(parser.extractGenerationParams({ workflow }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(workflow).prompts.negative, { status: 'unresolved', text: null, source: null });
// The same instance of a definition without a zero-out keeps the legacy fill.
workflow.definitions.subgraphs[0].nodes[0].type = 'Reroute';
assert.equal(workflowOnly(workflow).prompts.negative.text, 'a woman holding a coffee cup');
});
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);
@@ -555,3 +689,16 @@ test('a setting the file does not record is never filled from the legacy parse',
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');
});
+54
View File
@@ -0,0 +1,54 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/library-layout.mjs');
/** Runs `body` with the process time zone set to `zone`, restoring the previous one. */
async function inZone(zone, body) {
const previous = process.env.TZ;
process.env.TZ = zone;
try { await body(); } finally {
if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous;
}
}
test('used label boundaries in UTC and local time', async () => {
const { usedLabel } = await source;
await inZone('UTC', () => {
const now = new Date('2026-10-20T12:00:00Z');
assert.equal(usedLabel('2026-10-20T00:00:00Z', now), 'Used today');
assert.equal(usedLabel('2026-10-20T11:59:59Z', now), 'Used today');
assert.equal(usedLabel('2026-10-19T23:59:59Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-19T00:00:00Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-18T23:59:59Z', now), 'Used 2 days ago');
assert.equal(usedLabel('2026-10-14T00:00:00Z', now), 'Used 6 days ago');
assert.equal(usedLabel('2026-10-13T23:59:59Z', now), 'Used Oct 13');
assert.equal(usedLabel('2026-01-02T08:00:00Z', now), 'Used Jan 2');
// D23: a use in another calendar year names its year, so last year's Oct 21 never reads as this year's.
assert.equal(usedLabel('2025-10-21T08:00:00Z', now), 'Used Oct 21, 2025');
assert.equal(usedLabel('2025-12-31T23:59:59Z', now), 'Used Dec 31, 2025');
// A clock ahead of this browser is not a future use: it reads as today.
assert.equal(usedLabel('2026-10-21T09:00:00Z', now), 'Used today');
});
// The day boundary is the viewer's local midnight, not UTC's (PDT is UTC-7 in October).
await inZone('America/Los_Angeles', () => {
const now = new Date('2026-10-20T07:30:00Z');
assert.equal(usedLabel('2026-10-20T07:10:00Z', now), 'Used today');
assert.equal(usedLabel('2026-10-20T06:30:00Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-14T07:00:00Z', now), 'Used 6 days ago');
assert.equal(usedLabel('2026-10-14T06:59:59Z', now), 'Used Oct 13');
});
// Across the November DST change a calendar day is still one day.
await inZone('America/New_York', () => {
const now = new Date('2026-11-02T05:30:00Z');
assert.equal(usedLabel('2026-11-01T04:30:00Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-31T05:30:00Z', now), 'Used 2 days ago');
});
for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet');
});
test('rating label omits unrated', async () => {
const { ratingLabel } = await source;
assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' });
assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' });
for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' });
});
+11 -2
View File
@@ -2,12 +2,21 @@ const test=require('node:test');
const assert=require('node:assert/strict');
const source=import('../../web/js/workspace/library-state.mjs');
const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]});
const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor});
const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor});
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
});
test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{
const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}];
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.variantTotal,null);
await s.reset();assert.equal(s.state.variantTotal,6);await s.loadMore();assert.equal(s.state.variantTotal,7);
const reset=s.reset({text:'x'});assert.equal(s.state.variantTotal,null);await reset;assert.equal(s.state.variantTotal,1);
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.variantTotal,1);restored.dispose();
for(const bad of [undefined,-1,1.5,'2']){const broken=createLibraryStream({api:{get:async()=>({...page([item(1)]),variant_total:bad})}});await broken.reset();assert.ok(broken.state.error);assert.equal(broken.state.variantTotal,null);broken.dispose();}
s.dispose();
});
test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{
const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}});
const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]);
@@ -36,7 +45,7 @@ test('detail reads validate identities, abort stale selection and expose retryab
test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{
const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[];
const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose();
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}}));
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}}));
});
test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{
const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24);
+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)}…`);
});
+66 -8
View File
@@ -40,8 +40,10 @@ function mountedDom() {
set textContent(value){this._text=String(value);this.children=[];}
get childNodes(){return this.children;}
get isConnected(){return this.root || !!this.parent?.isConnected;}
append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}}
prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
// As in the DOM, a string argument becomes a text node.
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
setAttribute(key,value){this.attributes[key]=String(value);}
@@ -89,7 +91,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 +108,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();}
});
@@ -117,7 +119,7 @@ function currentInspectionEnvelope(target) {
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null}};
}
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
@@ -152,7 +154,7 @@ 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');
@@ -173,7 +175,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
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 preview')[0].fire('click');
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');
@@ -185,7 +187,63 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
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 preview')[0].fire('click');
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(); }
});
+202
View File
@@ -0,0 +1,202 @@
// Saved views (#249 D16): the state whitelist, the concept's default name, the listing parser and the shell's store.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/saved-views.mjs');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
assert.deepEqual(normalizeViewState('images', full), full);
// D51: favorites is a boolean; anything else is the whole listing.
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
// Each rejected value falls back on its own; the rest of the view still applies.
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source, folder or media, and no Name Z–A.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
const state = normalizeViewState('library', { tags });
state.tags.push('two');
assert.deepEqual(tags, ['one']);
assert.throws(() => normalizeViewState('tags', {}), TypeError);
});
test('the default name is the concept search name, else My workspace view, within the name limit', async () => {
const { defaultViewName } = await source;
assert.equal(defaultViewName('neon'), 'Search: neon');
assert.equal(defaultViewName(' neon city '), 'Search: neon city');
assert.equal(defaultViewName(''), 'My workspace view');
assert.equal(defaultViewName(' '), 'My workspace view');
assert.equal(defaultViewName(undefined), 'My workspace view');
assert.equal(defaultViewName('x'.repeat(500)).length, 100);
});
test('the listing keeps usable views in order and normalizes their state', async () => {
const { parseSavedViews, parseSavedView, savedViewHash } = await source;
const views = parseSavedViews({ success: true, views: [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 0, page: 'images', name: 'Bad id', state: {} },
{ id: 4, page: 'tags', name: 'Bad page', state: {} },
{ id: 5, page: 'images', name: ' ', state: {} },
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
] });
assert.deepEqual(views, [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
]);
assert.equal(parseSavedView(null), null);
assert.throws(() => parseSavedViews({ success: true }), TypeError);
assert.equal(savedViewHash(12), '#/saved/12');
});
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
function fakeApi() {
const calls = [];
const pending = [];
const api = {
calls,
get(url, options) {
calls.push(['GET', url, options.cache]);
return new Promise((resolve, reject) => pending.push({ resolve, reject, signal: options.signal }));
},
post(url, body) {
calls.push(['POST', url, body]);
return Promise.resolve({ success: true, view: { id: 9, ...body } });
},
patch(url, body) {
calls.push(['PATCH', url, body]);
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
},
del(url) {
calls.push(['DELETE', url]);
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
},
};
return { api, pending };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('the store reports loads, keeps the last views after a failed read, and a newer read supersedes', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const controller = new AbortController();
const store = createSavedViews(api, controller.signal);
const seen = [];
store.subscribe(next => seen.push([next.status, next.views.map(view => view.id)]));
store.load();
const settled = store.settled();
pending[0].resolve({ success: true, views: [{ id: 2, page: 'images', name: 'A', state: {} }] });
assert.equal((await settled).status, 'ready');
assert.deepEqual(store.find(2).state, IMAGE_DEFAULTS);
assert.equal(store.find(3), null);
assert.deepEqual(api.calls[0], ['GET', SAVED_VIEWS_URL, 'no-store']);
store.load();
pending[1].reject(new Error('offline'));
await flush();
assert.equal(store.status, 'error');
assert.deepEqual(store.views.map(view => view.id), [2]);
// A second read aborts the first; only the newer one lands.
store.load();
store.load();
assert.equal(pending[2].signal.aborted, true);
pending[2].resolve({ success: true, views: [] });
pending[3].resolve({ success: true, views: [{ id: 5, page: 'library', name: 'B', state: {} }] });
await flush();
assert.deepEqual(store.views.map(view => view.id), [5]);
assert.deepEqual(seen, [['loading', []], ['ready', [2]], ['loading', [2]], ['error', [2]], ['loading', [2]], ['loading', [2]], ['ready', [5]]]);
controller.abort();
});
test('creating a view posts the full state and re-reads the list', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const created = store.create('library', 'Neon', { q: 'neon' });
await flush();
assert.deepEqual(api.calls[0], ['POST', SAVED_VIEWS_URL, { page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } }]);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'library', name: 'Neon', state: { q: 'neon' } }] });
assert.deepEqual(await created, { id: 9, page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } });
assert.equal(store.find(9).name, 'Neon');
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
await assert.rejects(broken.create('images', 'X', {}), TypeError);
});
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const renamed = store.rename(9, 'Dusk');
await flush();
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
assert.equal(store.find(9).name, 'Dusk');
const removed = store.remove(9);
await flush();
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
pending[1].resolve({ success: true, views: [] });
assert.equal(await removed, 9);
assert.equal(store.find(9), null);
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
const gone = store.remove(4);
await flush();
pending[2].resolve({ success: true, views: [] });
assert.equal(await gone, 4);
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
const failed = store.remove(4);
await flush();
pending[3].resolve({ success: true, views: [] });
await assert.rejects(failed, /Locked/);
api.patchResult = Promise.resolve({ success: true, view: null });
const invalid = store.rename(4, 'X');
await flush();
pending[4].resolve({ success: true, views: [] });
await assert.rejects(invalid, TypeError);
assert.equal(pending.length, 5);
assert.throws(() => store.rename(0, 'X'), TypeError);
assert.throws(() => store.remove('9'), TypeError);
});
test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)', async () => {
const { createSavedViews, stateFits, SavedViewTooLarge, MAX_STATE_BYTES } = await source;
assert.equal(MAX_STATE_BYTES, 4096);
assert.equal(stateFits(IMAGE_DEFAULTS), true);
// Each value is within its own limit; their multibyte UTF-8 together passes 4 KB.
const big = { ...IMAGE_DEFAULTS, q: 'é'.repeat(500), folder: 'é'.repeat(1024), tags: Array.from({ length: 20 }, () => 'é'.repeat(50)) };
assert.equal(stateFits(big), false);
const { api } = fakeApi();
const store = createSavedViews(api);
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
assert.deepEqual(api.calls, []);
});
+134
View File
@@ -76,3 +76,137 @@ 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}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
assert.ok(counts instanceof Map);
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['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, favorites: invalid, library: {total: 2}, tags: invalid});
assert.equal(result.get('images'), null, JSON.stringify(invalid));
assert.equal(result.get('favorites'), null);
assert.equal(result.get('library'), 2);
assert.equal(result.get('tags'), null);
}
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', 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.includes('favorites=1')) return {success: true, total: 2};
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(), [['favorites', 2], ['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], ['favorites', 2], ['library', 3], ['tags', null]]);
});
test('nav counts issue four 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], ['favorites', 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/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.equal(urls[0].searchParams.has('favorites'), false);
// D51: the Favorites count is the Favorites page's own listing.
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
assert.equal(urls[1].searchParams.get('favorites'), '1');
assert.ok(calls.every(([, used]) => used === signal));
});
test('a favorite change re-reads only the Favorites count', async () => {
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
const calls = [];
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
// A real zero is a count, unlike an outstanding read.
assert.deepEqual([...counts], [['favorites', 0]]);
});
test('the Favorites route is native and resolves by its hash', async () => {
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
const route = resolveRoute('#/favorites');
assert.equal(route.id, 'favorites');
assert.equal(route.label, 'Favorites');
assert.equal(routeTitle(route), 'Favorites');
assert.equal(isHandoff(route), false);
// No href: Overview lists no legacy card for it.
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
});
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]]);
});
test('navigating to the current route re-renders it, as the concept go() does', async () => {
const {mountRouter,navigateTo}=await source;
const win=new EventTarget();win.location={hash:'#/library/12'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
navigateTo('#/library/12',win);
assert.deepEqual(seen,[['library',12],['library',12]]);
// A different route only sets the hash; its hashchange renders it once.
navigateTo('#/library/13',win);assert.equal(win.location.hash,'#/library/13');assert.equal(seen.length,2);
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
});
// Saved views (#249 D16): `#/saved/<id>` opens one persisted view; the shell resolves it to its page.
test('saved-view links resolve to their id and re-render only when the id changes', async () => {
const {resolveRoute,mountRouter,isHandoff,routes}=await source;
const route=resolveRoute('#/saved/7');
assert.deepEqual([route.id,route.savedId,route.hash],['saved',7,'#/saved/7']);
assert.equal(isHandoff(route),false);
assert.equal(routes.some(item=>item.id==='saved'),false,'saved views are not an Overview card or a WORKSPACE link');
for(const hash of ['#/saved','#/saved/0','#/saved/-2','#/saved/1.5','#/saved/9007199254740992','#/saved/3/x','#/saved/abc']) {
assert.equal(resolveRoute(hash).id,'images',hash);
}
const win=new EventTarget();win.location={hash:'#/saved/3'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.savedId]));
assert.equal(win.location.hash,'#/saved/3');
win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/images';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
dispose();
});
+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();
});
+724
View File
@@ -0,0 +1,724 @@
// 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 saves = [];
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),
onSaveView: trigger => saves.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', 'button']);
const [label, category, sort, saveButton, 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');
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
assert.equal(button(root, 'Save view'), saveButton);
assert.equal(saveButton.type, 'button');
saveButton.click();
assert.deepEqual(saves, [saveButton]);
assert.deepEqual(filters, []);
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() {}, onSaveView() {} }), TypeError);
// Every toolbar page offers Save view, so the callback is required.
assert.throws(() => mountToolbar(container, { scope, 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() {}, onSaveView() {} });
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() {}, onSaveView() {} });
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();
}
});
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
test('an input dialog with a fallback applies it for an empty value', async () => {
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const applied = [];
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
open('My workspace view');
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
const body = byId(dom, 'modal-body');
const [field, actions] = body.children;
const [label, input] = field.children;
assert.equal(label.textContent, 'View name');
assert.equal(input.id, 'action-input');
assert.equal(input.value, 'My workspace view');
assert.equal(input.maxLength, 100);
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
input.value = ' ';
actions.children[1].click();
assert.deepEqual(applied, ['Saved view']);
open('');
byId(dom, 'action-input').value = ' Castles ';
byId(dom, 'modal-body').children[1].children[1].click();
assert.deepEqual(applied, ['Saved view', 'Castles']);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), 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('a content dialog shows page nodes and releases them once on close or replacement', async () => {
const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
let closed = 0;
const grid = new dom.Element('div');
openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ });
assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive');
assert.deepEqual(byId(dom, 'modal-body').children, [grid]);
assert.equal(modal(dom).open, true);
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
assert.equal(closed, 1);
assert.equal(document.activeElement, trigger);
await tick();
assert.equal(closed, 1);
// Another dialog replacing the body releases it too, exactly once.
openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ });
openCopyFallback('swap', { scope });
assert.equal(closed, 2);
closeWorkspaceDialog();
assert.equal(closed, 2);
// Disposing the page scope closes it.
openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ });
scope.dispose();
assert.equal(closed, 3);
assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/);
const other = (await coreModule).createScope();
assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError);
other.dispose();
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs need the page scope and close with it', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
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');
});
// D44: the concept has no indicator, so a filter set in "Refine this view" (or by Browse) was invisible on the page.
test('the Filters button shows the active refinement count', async () => {
const { mountToolbar } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
const filtersButton = container.children[0].children[4];
// The visible text is the accessible name, so the count is spoken too.
assert.equal(filtersButton.textContent, 'Filters · 2');
toolbar.setActiveFilters(1);
assert.equal(filtersButton.textContent, 'Filters · 1');
toolbar.setActiveFilters(0);
assert.equal(filtersButton.textContent, 'Filters');
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
assert.equal(filtersButton.textContent, 'Filters');
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
} finally {
scope.dispose();
dom.restore();
}
});
+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();
});
+80
View File
@@ -279,6 +279,86 @@ class HistoryAPITests(ImageAPITestCase):
response, body, 409, "history-changed", {"action": "refresh-history"}
)
async def test_newest_pair_run_route_names_the_run_or_none(self):
pair = self.api.db.save_prompt_pair("positive", "negative")
quiet = self.api.db.save_prompt_pair("positive", "quiet")
foreign = self.api.db.save_prompt_pair("elsewhere", "negative")
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
self.capture(0, 100)
newest = self.capture(1, 200)
base = f"/prompt_manager/library/{positive}/history/pairs"
response = await self.client.get(f"{base}/{pair}/newest")
body = await response.json()
self.assertEqual(response.status, 200, body)
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
self.assertEqual(body["history_version"], 1)
self.assertEqual(body["history_epoch"], newest.storage_epoch)
self.assertEqual(body["run"]["run_id"], newest.run_id)
self.assertEqual(body["run"]["anchor_id"], "4")
response = await self.client.get(f"{base}/{quiet}/newest")
self.assertEqual((await response.json())["run"], None)
response = await self.client.get(f"{base}/{foreign}/newest")
self.assert_error(response, await response.json(), 404, "not-found")
response = await self.client.get(
f"/prompt_manager/library/999999/history/pairs/{pair}/newest"
)
self.assert_error(response, await response.json(), 404, "not-found")
for path in (f"{base}/0/newest", f"{base}/01/newest", f"{base}/true/newest"):
response = await self.client.get(path)
self.assert_error(response, await response.json(), 400, "invalid-request")
response = await self.client.get(f"{base}/{pair}/newest", params={"x": "1"})
self.assert_error(response, await response.json(), 400, "invalid-request")
async def test_newest_pair_run_route_reports_storage_and_epoch_failures(self):
pair = self.api.db.save_prompt_pair("positive", "negative")
saved = self.api.db.get_prompt_pair(pair)
self.capture(0, 100)
url = (
f"/prompt_manager/library/{saved['positive_prompt_id']}"
f"/history/pairs/{pair}/newest"
)
# A prompt saved only as a negative is not a Library positive.
response = await self.client.get(
f"/prompt_manager/library/{saved['negative_prompt_id']}"
f"/history/pairs/{pair}/newest"
)
self.assert_error(response, await response.json(), 404, "not-found")
private = "SECRET_PROMPT /private/data/secret.db"
for exc in (sqlite3.OperationalError("busy " + private), OSError(private)):
with patch.object(self.api.db, "get_newest_pair_run", side_effect=exc):
with self.assertLogs("prompt_manager.api", "WARNING") as logs:
response = await self.client.get(url)
body = await response.json()
self.assert_error(
response, body, 503, "storage-unavailable", {"action": "retry-read"}
)
self.assertNotIn(private, json.dumps(body) + " ".join(logs.output))
source = str(self.comfy_dir / "source.db")
self.assertTrue(self.api.db.model.backup_database(source))
writer = PromptDatabase(self.api.db.model.db_path)
original = self.api.db.get_newest_pair_run
def during_snapshot(*args, **kwargs):
result = original(*args, **kwargs)
with patch("database.execution_history.notify_history_restore"):
writer.model.restore_from_file(source)
return result
try:
with patch.object(
self.api.db, "get_newest_pair_run", side_effect=during_snapshot
):
response = await self.client.get(url)
self.assert_error(
response,
await response.json(),
409,
"history-changed",
{"action": "refresh-history"},
)
finally:
writer.close_all()
async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self):
start = make_start(
SESSION,
+113 -2
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))
@@ -340,10 +375,54 @@ class TestLibraryAPI(ImageAPITestCase):
result["image"],
{"root_index": 0, "path": relative, "filename": "猫 % #.png"},
)
self.assertEqual(result["library"]["metadata_scope"], "pair")
# D39: the recipe has no category or tags, so both fall back.
self.assertEqual(result["library"]["category_scope"], "positive")
self.assertEqual(result["library"]["tags_scope"], "positive")
self.assertEqual(result["library"]["pair_id"], pair)
self.assertEqual(result["library"]["positive_id"], positive)
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_library_gallery_reports_favorites_with_one_lookup_per_page(self):
# The Library-embedded media cards carry the Images star (#249 PR E):
# favorites come from the store keyed by root and relative path.
pair = self.api.db.save_prompt_pair("Cat", "Noise")
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
for index in range(5):
path = make_png(self.output_dir / "sub" / f"{index}.png")
image = self.api.db.link_image_to_prompt(positive, str(path))
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
response = await self.client.put(
"/prompt_manager/images/favorite",
json={"root_index": 0, "path": "sub/3.png"},
)
self.assertEqual(response.status, 200)
lookups = []
original = self.api.db.get_image_favorites
def counted(root_key, paths):
lookups.append(list(paths))
return original(root_key, paths)
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
stars = {image["path"]: image["favorite"] for image in gallery["images"]}
self.assertEqual(stars, {f"sub/{index}.png": index == 3 for index in range(5)})
# One batched read per page, never one per card.
self.assertEqual([len(paths) for paths in lookups], [5])
# Shelf previews show no star and read no favorites.
lookups.clear()
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
_, listing = await self.get()
self.assertEqual(lookups, [])
self.assertNotIn("favorite", listing["items"][0]["previews"][0])
response = await self.client.delete(
"/prompt_manager/images/favorite",
json={"root_index": 0, "path": "sub/3.png"},
)
self.assertEqual(response.status, 200)
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
self.assertFalse(any(image["favorite"] for image in gallery["images"]))
async def test_epoch_scoped_all_library_cursors(self):
from database.execution_history_schema import rotate_history_epoch
@@ -438,6 +517,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
+68 -3
View File
@@ -128,7 +128,12 @@ class TestPromptInspector(ImageAPITestCase):
for expected in ("ambiguous", "unresolved"):
source = graph()
if expected == "ambiguous":
source["4"] = source["3"]
# Branches with different prompts (#249: identical ones resolve).
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}
source["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
else:
source["1"]["class_type"] = "UnknownEncoder"
png(
@@ -1302,6 +1307,64 @@ class TestPromptInspector(ImageAPITestCase):
)
roots.assert_called_once_with()
async def test_recipe_link_falls_back_to_positive_category_and_tags_per_field(
self,
):
# D39 ruling (2026-10-21): a recipe's own category and tags win when set;
# otherwise each field falls back to the linked positive's, separately, and
# the scopes say where each value came from (no merging of tag sets).
cases = (
("Recipe cat", ["recipe"], "Recipe cat", "pair", ["recipe"], "pair"),
(None, [], "Positive cat", "positive", ["positive"], "positive"),
(" ", [], "Positive cat", "positive", ["positive"], "positive"),
("Recipe cat", [], "Recipe cat", "pair", ["positive"], "positive"),
(None, ["recipe"], "Positive cat", "positive", ["recipe"], "pair"),
)
for index, case in enumerate(cases):
recipe_category, recipe_tags = case[:2]
category, category_scope, tags, tags_scope = case[2:]
with self.subTest(case=index):
text = f"Fallback owner {index}"
positive = self._save_prompt(
text, category="Positive cat", tags=["positive"]
)
pair = self.api.db.save_prompt_pair(
text,
f"Fallback noise {index}",
category=recipe_category,
tags=recipe_tags,
)
self.assertEqual(
self.api.db.get_prompt_pair(pair)["positive_prompt_id"], positive
)
filename = f"fallback-{index}.png"
path = png(self.output_dir / filename)
image = self.api.db.link_image_to_prompt(positive, str(path))
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
response, result = await self.inspect(filename)
self.assertEqual(response.status, 200)
library = result["library"]
self.assertEqual(library["status"], "linked")
self.assertEqual(library["pair_id"], pair)
self.assertEqual(library["category"], category)
self.assertEqual(library["category_scope"], category_scope)
self.assertEqual(library["tags"], tags)
self.assertEqual(library["tags_scope"], tags_scope)
self.assertNotIn("metadata_scope", library)
async def test_positive_only_link_keeps_positive_scopes(self):
positive = self._save_prompt("Solo owner", tags=["solo"])
path = png(self.output_dir / "solo.png")
self.api.db.link_image_to_prompt(positive, str(path))
response, result = await self.inspect("solo.png")
self.assertEqual(response.status, 200)
library = result["library"]
self.assertEqual(library["pair_id"], None)
self.assertEqual(library["category"], None)
self.assertEqual(library["category_scope"], "positive")
self.assertEqual(library["tags"], ["solo"])
self.assertEqual(library["tags_scope"], "positive")
async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence(
self,
):
@@ -1340,8 +1403,9 @@ class TestPromptInspector(ImageAPITestCase):
if count == 1:
self.assertEqual(result["library"]["positive_id"], positive)
self.assertEqual(
result["library"]["metadata_scope"], "positive"
result["library"]["category_scope"], "positive"
)
self.assertEqual(result["library"]["tags_scope"], "positive")
self.assertEqual(result["library"]["category"], "Category 1")
self.assertEqual(result["library"]["tags"], ["owner-1"])
if not readable:
@@ -1361,7 +1425,8 @@ class TestPromptInspector(ImageAPITestCase):
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
},
)
for field in (
+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()
+85 -52
View File
@@ -24,6 +24,11 @@ except ImportError:
import test_execution_history_writer as fixtures
# Upper bound a blocked fake write waits for its release. Tests prove ordering
# with events, so this only caps a failing run and never decides a pass.
BLOCK_SECONDS = 10
class OutputWriterTests(fixtures.unittest.TestCase):
setUp = fixtures.HistoryWriterTests.setUp
cleanup_workers = fixtures.HistoryWriterTests.cleanup_workers
@@ -408,59 +413,81 @@ class OutputWriterTests(fixtures.unittest.TestCase):
def test_bounded_close_and_early_native_join_keep_inflight_combined_charge(self):
for early in (False, True):
with self.subTest(early_join=early):
writer = self.writer()
start = self.start_command(len(self.writers))
self.db.write_execution_start(start)
entered, release = self.block()
original = self.db.write_execution_outputs_for_finish
self.assert_bounded_close_keeps_inflight_charge(early)
def write(*args, **kwargs):
entered.set()
release.wait(3)
return original(*args, **kwargs)
def assert_bounded_close_keeps_inflight_charge(self, early):
# Every writer in this test charges the process-wide budget, so a
# failing subtest must not leave its worker blocked (and charged) for
# the next one: release and join it before the next subtest starts.
writer = self.writer()
self.assertEqual(writer.context_budget.charged_bytes, 0)
start = self.start_command(len(self.writers))
self.db.write_execution_start(start)
entered, release = self.block()
left = threading.Event()
original = self.db.write_execution_outputs_for_finish
with patch.object(self.db, "write_execution_outputs_for_finish", write):
self.finalization(writer, start)
self.assertTrue(entered.wait(1))
charge = writer.context_budget.charged_bytes
self.finalization(writer, start)
clock = fixtures.ManualClock()
native_join = writer._thread.join
def write(*args, **kwargs):
entered.set()
release.wait(BLOCK_SECONDS)
left.set()
return original(*args, **kwargs)
def return_early(timeout):
native_join(0)
clock.advance(min(timeout, 0.004))
try:
with patch.object(self.db, "write_execution_outputs_for_finish", write):
self.finalization(writer, start)
self.assertTrue(entered.wait(BLOCK_SECONDS))
charge = writer.context_budget.charged_bytes
self.finalization(writer, start)
clock = fixtures.ManualClock()
native_join = writer._thread.join
def sleep(delay):
clock.advance(delay)
def return_early(timeout):
native_join(0)
clock.advance(min(timeout, 0.004))
clock_patch = (
patch(
"utils.execution_history_writer.time",
SimpleNamespace(monotonic=clock, sleep=sleep),
)
if early
else nullcontext()
def sleep(delay):
clock.advance(delay)
clock_patch = (
patch(
"utils.execution_history_writer.time",
SimpleNamespace(monotonic=clock, sleep=sleep),
)
join_patch = (
patch.object(writer._thread, "join", return_early)
if early
else nullcontext()
)
with clock_patch, join_patch:
before = clock() if early else time.monotonic()
self.assertFalse(writer.close(0.02))
elapsed = (clock() if early else time.monotonic()) - before
self.assertGreaterEqual(elapsed, 0.02)
if early
else nullcontext()
)
join_patch = (
patch.object(writer._thread, "join", return_early)
if early
else nullcontext()
)
with clock_patch, join_patch:
before = clock() if early else time.monotonic()
self.assertFalse(writer.close(0.02))
elapsed = (clock() if early else time.monotonic()) - before
# close() waits until its own monotonic deadline, so the lower
# bound holds on any runner.
self.assertGreaterEqual(elapsed, 0.02)
if early:
# Manual clock: only the patched join/sleep advance it, so
# this exact bound proves early joins do not spin forever.
self.assertLess(elapsed, 0.2)
self.assertEqual(writer.context_budget.charged_bytes, charge)
replacement = HistoryWriter(lambda: self.db)
self.writers.append(replacement)
self.assertIs(replacement.context_budget, writer.context_budget)
self.assertIsNone(replacement.context_budget.reserve(8388608))
release.set()
self.assertTrue(writer.close(2))
self.assertEqual(writer.context_budget.charged_bytes, 0)
# Bounded, not unbounded: close() gave up while the in-flight
# write was still blocked, instead of waiting it out.
self.assertFalse(left.is_set())
self.assertTrue(writer._thread.is_alive())
self.assertEqual(writer.context_budget.charged_bytes, charge)
replacement = HistoryWriter(lambda: self.db)
self.writers.append(replacement)
self.assertIs(replacement.context_budget, writer.context_budget)
self.assertIsNone(replacement.context_budget.reserve(8388608))
release.set()
self.assertTrue(writer.close(2))
finally:
release.set()
writer.close(BLOCK_SECONDS)
self.assertEqual(writer.context_budget.charged_bytes, 0)
def test_pending_saturation_and_invalid_command_release_both_payloads(self):
writer = self.writer()
@@ -552,11 +579,13 @@ class OutputWriterTests(fixtures.unittest.TestCase):
self.db.write_execution_start(start)
entered, release = self.block()
invalidating, invalidated = threading.Event(), threading.Event()
committed = threading.Event()
original = outputs_db._capture
def capture(*args, **kwargs):
entered.set()
release.wait(3)
release.wait(BLOCK_SECONDS)
committed.set()
return original(*args, **kwargs)
def invalidate():
@@ -566,20 +595,24 @@ class OutputWriterTests(fixtures.unittest.TestCase):
with patch.object(outputs_db, "_capture", capture):
self.finalization(writer, start)
self.assertTrue(entered.wait(1))
self.assertTrue(entered.wait(BLOCK_SECONDS))
invalidator = threading.Thread(target=invalidate)
invalidator.start()
try:
self.assertTrue(invalidating.wait(1))
self.assertTrue(invalidating.wait(BLOCK_SECONDS))
self.assertFalse(invalidated.wait(0.03))
before = time.monotonic()
self.assertTrue(writer.submit(self.finish_command(start)))
self.assertFalse(writer.close(0))
self.assertLess(time.monotonic() - before, 0.1)
# submit() and close() returned while the commit still held the
# gate: the blocked invalidation has not finished and the
# commit is still waiting for its release. Had either call
# queued behind the gate, it could only return after both.
self.assertFalse(invalidated.is_set())
self.assertFalse(committed.is_set())
self.assertGreater(writer.context_budget.charged_bytes, 0)
finally:
release.set()
invalidator.join(3)
invalidator.join(BLOCK_SECONDS)
self.assertFalse(invalidator.is_alive())
self.assertTrue(writer.close(2))
self.assertEqual(
+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()
+88
View File
@@ -16,6 +16,7 @@ from tests.test_api_images import make_png
from database.operations import PromptDatabase
from py.config import GalleryConfig
from utils.execution_outputs import snapshot_output_roots
from utils.hashing import generate_prompt_hash
class CurrentLocationTests(unittest.TestCase):
@@ -331,6 +332,93 @@ class CurrentLocationTests(unittest.TestCase):
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
def test_gallery_recipe_display_falls_back_to_positive_per_field(self):
# D39 ruling (2026-10-21), shared with the Images inspector.
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
cases = (
("Recipe cat", ["recipe"], ("Recipe cat", "pair", ["recipe"], "pair")),
(None, [], ("Positive cat", "positive", ["positive"], "positive")),
("Recipe cat", [], ("Recipe cat", "pair", ["positive"], "positive")),
("", ["recipe"], ("Positive cat", "positive", ["recipe"], "pair")),
)
for index, (recipe_category, recipe_tags, expected) in enumerate(cases):
with self.subTest(case=index):
text = f"owner {index}"
positive = db.save_prompt(
text,
prompt_hash=generate_prompt_hash(text),
category="Positive cat",
)
db.set_prompt_tags(positive, ["positive"])
pair = db.save_prompt_pair(
text, "noise", category=recipe_category, tags=recipe_tags
)
path = make_png(self.output / f"fallback-{index}.png")
locator = os.path.normcase(str(path))
image = db.link_image_to_prompt(positive, str(path))
db.link_image_to_pair(pair, image, {"source": "fixture"})
conn = db.model.get_connection()
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))
library = result[locator]["library"]
self.assertEqual(library["pair_id"], pair)
self.assertEqual(
(
library["category"],
library["category_scope"],
library["tags"],
library["tags_scope"],
),
expected,
)
def test_gallery_blank_recipe_category_and_skipped_positive_tags_query(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "blank.db"))
self.addCleanup(db.close_all)
cases = (
(" ", ["recipe"], 0),
("\u3000", [], 1),
("\u00a0 ", ["recipe"], 0),
)
for index, (blank, recipe_tags, positive_tag_queries) in enumerate(cases):
with self.subTest(case=index):
text = f"blank owner {index}"
positive = db.save_prompt(
text,
prompt_hash=generate_prompt_hash(text),
category="Positive cat",
)
db.set_prompt_tags(positive, ["positive"])
pair = db.save_prompt_pair(text, "noise", tags=recipe_tags)
path = make_png(self.output / f"blank-{index}.png")
locator = os.path.normcase(str(path))
image = db.link_image_to_prompt(positive, str(path))
db.link_image_to_pair(pair, image, {"source": "fixture"})
conn = db.model.get_connection()
# Legacy rows can hold a blank-looking category the writers now trim.
conn.execute(
"UPDATE prompt_pairs SET category=? WHERE id=?", (blank, pair)
)
conn.commit()
statements = []
conn.set_trace_callback(statements.append)
self.addCleanup(conn.set_trace_callback, None)
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))
conn.set_trace_callback(None)
library = result[locator]["library"]
self.assertEqual(library["category"], "Positive cat")
self.assertEqual(library["category_scope"], "positive")
tag_queries = [
statement
for statement in statements
if "FROM prompt_tags pt" in statement
]
self.assertEqual(len(tag_queries), positive_tag_queries, statements)
def test_host_root_failure_and_removal_do_not_search_another_root(self):
make_png(self.output / "image.png")
make_png(self.temp / "image.png")
+76
View File
@@ -913,3 +913,79 @@ class HistoryReadTests(unittest.TestCase):
db.list_history_outputs(
start.run_id, after=OutputPosition("0" * 64, (node, 0))
)
def test_newest_pair_run_follows_current_hints_in_the_raw_scope(self):
db = self.database()
pair_a = db.save_prompt_pair("positive", "A")
pair_b = db.save_prompt_pair("positive", "B")
quiet = db.save_prompt_pair("positive", "C")
other = db.save_prompt_pair("elsewhere", "A")
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
self.capture(db, 0, bundle("A"), time=100)
newest = self.capture(db, 1, bundle("A"), time=200)
b = self.capture(db, 2, bundle("B"), time=300)
with db.recorded_history_snapshot():
found = db.get_newest_pair_run(positive, pair_a)
found_b = db.get_newest_pair_run(positive, pair_b)
none = db.get_newest_pair_run(positive, quiet)
foreign = db.get_newest_pair_run(positive, other)
missing = db.get_newest_pair_run(9999, pair_a)
self.assertEqual(found["run"]["run_id"], newest.run_id)
self.assertEqual(found["run"]["anchor_id"], "4")
self.assertRegex(found["run"]["context_id"], "^[0-9a-f]{64}$")
self.assertEqual(found_b["run"]["run_id"], b.run_id)
self.assertEqual(none, {"run": None})
self.assertIsNone(foreign)
self.assertIsNone(missing)
# A hint that no longer names the current pair is not followed.
db.delete_prompt_pair(pair_b)
recreated = db.save_prompt_pair("positive", "B")
with db.recorded_history_snapshot():
self.assertEqual(db.get_newest_pair_run(positive, recreated), {"run": None})
with self.assertRaises(sqlite3.ProgrammingError):
db.get_newest_pair_run(positive, pair_a)
def test_newest_pair_run_skips_stale_epoch_and_bounds_stale_hints(self):
db = self.database()
pair_a = db.save_prompt_pair("positive", "A")
pair_b = db.save_prompt_pair("positive", "B")
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
older = self.capture(db, 0, bundle("A"), time=100)
stale_epoch = self.capture(db, 1, bundle("A"), time=200)
conn = db.model.get_connection()
# A row whose reference epoch is not the current one is never followed.
conn.execute(
"UPDATE execution_run_contexts SET reference_epoch=? WHERE run_id=?",
("00000000-0000-4000-8000-000000000000", stale_epoch.run_id),
)
conn.commit()
with db.recorded_history_snapshot():
self.assertEqual(
db.get_newest_pair_run(positive, pair_a)["run"]["run_id"],
older.run_id,
)
# Seventeen newer rows whose hint names pair A but whose texts resolve to
# pair B fill the candidate bound, so the older valid run is not reached.
for item in range(2, 19):
start = self.capture(db, item, bundle("B"), time=300 + item)
conn.execute(
"UPDATE execution_run_contexts SET pair_id=? WHERE run_id=?",
(pair_a, start.run_id),
)
conn.commit()
with db.recorded_history_snapshot():
self.assertEqual(db.get_newest_pair_run(positive, pair_a), {"run": None})
self.assertIsNotNone(db.get_newest_pair_run(positive, pair_b))
def test_newest_pair_run_rejects_an_ineligible_or_deleted_positive(self):
db = self.database()
pair = db.save_prompt_pair("positive", "negative")
saved = db.get_prompt_pair(pair)
self.capture(db, 0, bundle("negative"), time=100)
with db.recorded_history_snapshot():
# A prompt saved only as a pair's negative is not a Library positive.
self.assertIsNone(db.get_newest_pair_run(saved["negative_prompt_id"], pair))
db.delete_prompt_pair(pair)
db.delete_prompt(saved["positive_prompt_id"])
with db.recorded_history_snapshot():
self.assertIsNone(db.get_newest_pair_run(saved["positive_prompt_id"], pair))
+248
View File
@@ -0,0 +1,248 @@
"""Several sampler branches that share one prompt pair resolve to that pair (#249).
The shape is a LoRA epoch-compare grid: one sampler per LoRA epoch, every
sampler fed by the same positive ``CLIPTextEncode`` and the same
``ConditioningZeroOut`` negative, every decoded image labelled, batched and
concatenated into one grid that a single ``SaveImage`` writes. Which branch
made which tile does not matter to the prompt: every branch used the same pair.
Branches that differ stay ambiguous.
"""
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import resolve_prompt_pair
POSITIVE = "a woman holding a coffee cup, in a beanie, sitting by a window"
OTHER = "a man reading a newspaper on a park bench"
NEGATIVE = "blurry, low quality"
# Each profile with the negative it can read: inspection reads the zero-out.
PROFILES = {
"default": (resolver.DEFAULT_PROFILE, NEGATIVE),
"inspection": (resolver.INSPECTION_PROFILE, None),
}
def _grid(epochs: int = 4, positives: dict = None, negative: str = None) -> dict:
"""A compare grid; ``positives`` maps an epoch to its own positive text.
The negative is a ``ConditioningZeroOut`` of the positive, or with
``negative`` an encoded text (the default profile cannot read a zero-out).
"""
graph = {
"1": {
"class_type": "UNETLoader",
"inputs": {"unet_name": "base.safetensors", "weight_dtype": "default"},
},
"2": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": "te.safetensors", "type": "stable_diffusion"},
},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "vae.safetensors"}},
"4": {
"class_type": "CLIPTextEncode",
"inputs": {"text": POSITIVE, "clip": ["2", 0]},
},
"5": (
{"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}}
if negative is None
else {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["2", 0]},
}
),
"6": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
},
}
batch = {}
for epoch in range(epochs):
base = 10 + epoch * 10
lora, sampler, decode, label = (str(base + offset) for offset in range(4))
positive = "4"
if positives and epoch in positives:
positive = str(base + 5)
graph[positive] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": positives[epoch], "clip": ["2", 0]},
}
graph[lora] = {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"lora_name": f"subject_epoch_{epoch:02d}.safetensors",
"strength_model": 1.0,
"model": ["1", 0],
},
}
graph[sampler] = {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 8,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1.0,
"model": [lora, 0],
"positive": [positive, 0],
"negative": ["5", 0],
"latent_image": ["6", 0],
},
}
graph[decode] = {
"class_type": "VAEDecode",
"inputs": {"samples": [sampler, 0], "vae": ["3", 0]},
}
graph[label] = {
"class_type": "AddLabel",
"inputs": {"text": f"epoch {epoch}", "image": [decode, 0]},
}
batch[f"images.image{epoch}"] = [label, 0]
graph["900"] = {"class_type": "BatchImagesNode", "inputs": batch}
graph["901"] = {
"class_type": "ImageConcatFromBatch",
"inputs": {"num_columns": 2, "images": ["900", 0]},
}
graph["902"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "compare/grid", "images": ["901", 0]},
}
return graph
def _texts(result: dict) -> tuple:
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
class TestIdenticalSamplerBranches(unittest.TestCase):
def test_grid_of_identical_branches_resolves_to_the_shared_pair(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair(
{"prompt": _grid(negative=negative)}, profile=profile
)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (POSITIVE, negative or ""))
# No one branch is named: every branch made this pair.
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
# Every branch stays listed as the evidence for the shared pair.
self.assertEqual(
[item["sampler_id"] for item in result["candidates"]],
["11", "21", "31", "41"],
)
self.assertEqual(
{_texts(item) for item in result["candidates"]},
{(POSITIVE, negative or "")},
)
self.assertEqual(result["reason_code"], None)
if negative is None:
self.assertEqual(
result["negative"]["provenance"]["text_source"],
"zeroed-conditioning",
)
def test_default_profile_keeps_an_unreadable_zero_out_grid_ambiguous(self):
# Ingestion cannot read ConditioningZeroOut: every branch is unresolved,
# so the grid stays ambiguous and the scan keeps its legacy fallback.
result = resolve_prompt_pair({"prompt": _grid()})
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
self.assertEqual(
{item["state"] for item in result["candidates"]}, {"unresolved"}
)
def test_single_branch_grid_still_names_its_sampler(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair(
{"prompt": _grid(epochs=1, negative=negative)}, profile=profile
)
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["sampler_id"], "11")
self.assertEqual(result["reason"], "")
def test_branches_with_different_prompts_stay_ambiguous(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
source = _grid(positives={2: OTHER}, negative=negative)
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
self.assertIsNone(result["positive"])
self.assertEqual(
sorted(_texts(item)[0] for item in result["candidates"]),
sorted([POSITIVE] * 3 + [OTHER]),
)
def test_a_branch_that_cannot_be_read_keeps_the_grid_ambiguous(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
source = _grid(negative=negative)
source["31"]["inputs"]["negative"] = ["800", 0]
source["800"] = {"class_type": "MysteryConditioning", "inputs": {}}
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
def test_identical_unreadable_branches_are_not_collapsed(self):
source = _grid(epochs=2)
source["5"] = {"class_type": "MysteryConditioning", "inputs": {}}
for name, (profile, _negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
def test_same_text_with_a_different_recipe_stays_ambiguous(self):
adapters = {
("RecipeText", 0): resolver.OutputAdapter(
"text", "conditioning", compose=True
)
}
source = _grid(epochs=2, negative=NEGATIVE)
source["21"]["inputs"]["positive"] = ["700", 0]
source["700"] = {
"class_type": "RecipeText",
"inputs": {"prepend_text": "a woman", "text": "holding a coffee cup"},
}
alone = resolve_prompt_pair(
{"prompt": source}, output_node_id="21", adapters=adapters
)
# Branch "10" reads the same effective text through a plain encoder.
source["4"]["inputs"]["text"] = alone["positive"]["text"]
result = resolve_prompt_pair({"prompt": source}, adapters=adapters)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
{_texts(item) for item in result["candidates"]},
{(alone["positive"]["text"], NEGATIVE)},
)
self.assertEqual(
{item["positive"]["storage_text"] for item in result["candidates"]},
{alone["positive"]["text"], "holding a coffee cup"},
)
def test_inspector_shows_the_positive_and_an_explicitly_empty_negative(self):
evidence = inspect_prompt_evidence({"prompt": _grid()})
prompts = evidence["prompts"]
self.assertEqual(prompts["positive"]["status"], "resolved")
self.assertEqual(prompts["positive"]["text"], POSITIVE)
self.assertEqual(prompts["negative"]["status"], "resolved")
self.assertEqual(prompts["negative"]["text"], "")
self.assertEqual(prompts["negative"]["source"], "zeroed-conditioning")
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertIsNone(evidence["resolution"]["sampler_id"])
def test_inspector_keeps_differing_grids_ambiguous(self):
evidence = inspect_prompt_evidence({"prompt": _grid(positives={0: OTHER})})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(evidence["prompts"]["positive"]["status"], "ambiguous")
self.assertNotIn(POSITIVE, str(evidence["prompts"]))
if __name__ == "__main__":
unittest.main()
+515
View File
@@ -0,0 +1,515 @@
"""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 TestWorkspaceFavoritesFilter(ImageAPITestCase):
"""``favorites=1`` on the workspace listing: the Favorites page (#249 D51)."""
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 page(self, status=200, **params):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace", **params}
)
data = await response.json()
self.assertEqual(response.status, status, data)
return data
async def star(self, path, root_index=0, method="PUT"):
response = await self.client.request(
method, FAVORITE, json={"root_index": root_index, "path": path}
)
self.assertEqual(response.status, 200)
def make(self, *names):
for offset, name in enumerate(names):
target = make_png(self.output_dir / name)
os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset))
async def test_lists_only_favorites_and_counts_them(self):
self.make("a.png", "sub/b.png", "c.png", "sub/d.png")
await self.star("a.png")
await self.star("sub/d.png")
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"])
self.assertEqual(data["total"], 2)
self.assertTrue(all(i["favorite"] is True for i in data["images"]))
# The nav count's read: limit=1 still reports every favorite's total.
self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2)
# Other filters intersect with it.
sub = await self.page(favorites="1", subfolder="sub")
self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"])
self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1)
self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0)
# favorites=0 (and no parameter) is the whole listing.
self.assertEqual((await self.page(favorites="0"))["total"], 4)
self.assertEqual((await self.page())["total"], 4)
await self.star("a.png", method="DELETE")
self.assertEqual((await self.page(favorites="1"))["total"], 1)
async def test_cursor_pages_walk_only_favorites_in_every_order(self):
self.make("a.png", "b.png", "c.png", "d.png", "e.png")
for name in ("a.png", "c.png", "e.png"):
await self.star(name)
expected = {
"newest": ["e.png", "c.png", "a.png"],
"oldest": ["a.png", "c.png", "e.png"],
"name": ["a.png", "c.png", "e.png"],
"name-desc": ["e.png", "c.png", "a.png"],
"rating": ["e.png", "c.png", "a.png"],
}
for sort, names in expected.items():
seen, cursor = [], None
while True:
params = {"favorites": "1", "sort": sort, "limit": "1"}
if cursor:
params["cursor"] = cursor
data = await self.page(**params)
self.assertEqual(data["total"], 3, sort)
seen += [i["path"] for i in data["images"]]
cursor = data["next_cursor"]
if not data["has_more"]:
break
self.assertEqual(seen, names, sort)
# A cursor is bound to its filters: a whole-listing cursor is refused.
whole = await self.page(limit="1")
await self.page(400, favorites="1", cursor=whole["next_cursor"])
async def test_favorites_follow_their_own_root(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]
await self.star("a.png", root_index=1)
data = await self.page(favorites="1")
self.assertEqual(
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
)
self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0)
self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1)
async def test_windows_case_folded_favorite_is_listed(self):
make_png(self.output_dir / "Sub" / "A.png")
with patch("py.api.images._path_flavor", ntpath):
await self.star("Sub/A.png")
(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()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"])
async def test_a_favorite_whose_file_is_gone_is_not_listed(self):
self.make("a.png", "b.png")
await self.star("a.png")
await self.star("b.png")
(self.output_dir / "b.png").unlink()
self.api.invalidate_gallery_cache()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["a.png"])
self.assertEqual(data["total"], 1)
async def test_rejects_other_values(self):
for value in ("true", "2", "", "yes"):
data = await self.page(400, favorites=value)
self.assertFalse(data["success"])
async def test_store_failure_is_an_error_not_an_unfiltered_page(self):
self.make("a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
self.api.db.get_root_favorites = broken
data = await self.page(500, favorites="1")
self.assertFalse(data["success"])
self.assertNotIn("images", data)
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())
# One root's favorites, read through the primary key.
self.assertEqual(db.get_root_favorites("output"), {"a.png"})
self.assertEqual(db.get_root_favorites("other"), {"a.png"})
self.assertEqual(db.get_root_favorites("missing"), 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()
+919
View File
@@ -0,0 +1,919 @@
"""Show in folder (TESTING.md D38): the local-only reveal route and its openers.
No test launches a file manager: ``subprocess.Popen``, ``sys.platform`` and
``shutil.which`` are mocked, and route tests swap the API's opener.
"""
import ctypes
import json
import logging
import os
import subprocess
import sys
import tempfile
import types
import unittest
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
from aiohttp import web
from multidict import CIMultiDict
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
import utils.file_reveal as file_reveal
from utils.file_reveal import (
OpenerUnavailable,
is_local_request,
reveal_in_file_manager,
)
REVEAL = "/prompt_manager/images/reveal"
PROXY_HEADERS = (
"Forwarded",
"Via",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Forwarded-Port",
"X-Forwarded-Server",
"X-Real-IP",
"X-Original-Forwarded-For",
"X-Original-Host",
"CF-Connecting-IP",
"True-Client-IP",
"X-Client-IP",
"X-Cluster-Client-IP",
"Fastly-Client-IP",
)
ORIGIN = "http://127.0.0.1:8188"
def headers(**values):
base = {"Host": "127.0.0.1:8188", "Origin": ORIGIN}
base.update({key.replace("_", "-"): value for key, value in values.items()})
return CIMultiDict({key: value for key, value in base.items() if value is not None})
class TestIsLocalRequest(unittest.TestCase):
def test_same_origin_loopback_clients_by_loopback_name_are_local(self):
for remote, host, origin in (
("127.0.0.1", "127.0.0.1:8188", ORIGIN),
("127.8.9.10", "localhost:8188", "http://localhost:8188"),
("::1", "[::1]:8188", "http://[::1]:8188"),
("::ffff:127.0.0.1", "comfy.localhost", "http://comfy.localhost"),
("127.0.0.1", "LOCALHOST:8188", "http://localhost:8188"),
("127.0.0.1", "localhost", "http://localhost:80"),
):
with self.subTest(remote=remote, host=host):
self.assertTrue(
is_local_request(remote, headers(Host=host, Origin=origin))
)
self.assertTrue(
is_local_request(
"127.0.0.1",
headers(Host="localhost", Origin="https://localhost:443"),
"https",
)
)
def test_non_loopback_or_unknown_remote_is_not_local(self):
for remote in ("192.168.1.5", "10.0.0.1", "::2", "fe80::1%eth0", "", None):
with self.subTest(remote=remote):
self.assertFalse(is_local_request(remote, headers()))
def test_any_forwarding_header_makes_loopback_remote(self):
for name in (*PROXY_HEADERS, "X-Forwarded-Anything", "X-Original-URI"):
for spelling in (name, name.lower()):
with self.subTest(header=spelling):
request_headers = headers()
request_headers[spelling] = "127.0.0.1"
self.assertFalse(is_local_request("127.0.0.1", request_headers))
def test_origin_is_required_and_must_be_the_request_origin(self):
# A header-less proxy or curl leaves Origin out; a page on another
# loopback port or scheme sends its own (security review M1, L2).
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=None)))
for origin in (
"",
"null",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://127.0.0.1",
"https://127.0.0.1:8188",
"http://localhost:8188",
"http://foo.localhost:8188",
"http://evil.example:8188",
"http://[bad",
"http://127.0.0.1:8188/path",
"http://user@127.0.0.1:8188",
"file://127.0.0.1:8188",
):
with self.subTest(origin=origin):
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=origin)))
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=ORIGIN), "https"))
def test_sec_fetch_site_must_be_same_origin_when_sent(self):
self.assertTrue(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site="same-origin"))
)
for site in ("same-site", "cross-site", "none", ""):
with self.subTest(site=site):
self.assertFalse(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site=site))
)
def test_foreign_host_is_not_local(self):
# A DNS-rebinding page reaches 127.0.0.1 under its own name.
for host in ("evil.example:8188", "", "[bad", "127.0.0.1:bad"):
with self.subTest(host=host):
self.assertFalse(
is_local_request(
"127.0.0.1", headers(Host=host, Origin=f"http://{host}")
)
)
self.assertFalse(is_local_request("127.0.0.1", CIMultiDict({"Origin": ORIGIN})))
class TestRevealGate(unittest.TestCase):
def test_one_reveal_at_a_time_then_a_cooldown(self):
now = [100.0]
gate = file_reveal.RevealGate(cooldown=1.0, clock=lambda: now[0])
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
now[0] += 5
self.assertFalse(gate.try_enter())
gate.leave()
now[0] += 0.5
self.assertFalse(gate.try_enter())
now[0] += 0.5
self.assertTrue(gate.try_enter())
def test_zero_cooldown_only_blocks_while_busy(self):
gate = file_reveal.RevealGate(cooldown=0)
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
gate.leave()
self.assertTrue(gate.try_enter())
class TestRevealInFileManager(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temp.cleanup)
self.root = Path(os.path.realpath(temp.name)) / "out"
self.path = self.root / "sub" / "a.png"
self.path.parent.mkdir(parents=True)
self.path.write_bytes(b"png")
self.popen = patch.object(subprocess, "Popen").start()
self.reaped = patch.object(file_reveal, "_reap").start()
self.addCleanup(patch.stopall)
def reveal(self, path=None):
reveal_in_file_manager(self.path if path is None else path, self.root)
def which(self, available):
def lookup(name, path=None):
self.assertEqual(path, file_reveal.SYSTEM_PATH)
return available.get(name)
return patch.object(file_reveal.shutil, "which", side_effect=lookup)
def argv(self, call=0):
return self.popen.call_args_list[call].args[0]
def assert_detached(self, call=0):
options = self.popen.call_args_list[call].kwargs
for stream in ("stdin", "stdout", "stderr"):
self.assertIs(options[stream], subprocess.DEVNULL)
self.assertIs(options["close_fds"], True)
self.assertIs(options["start_new_session"], True)
self.assertNotIn("shell", options)
self.assertEqual(options["env"]["PATH"], file_reveal.SYSTEM_PATH)
def windows(self, selected=True, startfile=None):
"""Windows without Windows: the shell libraries and os.startfile
injected; the patched shell records its calls."""
shell = FakeShell(select=0 if selected else -2147467259)
select = patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
)
select.shell = shell
start = patch.object(
file_reveal.os, "startfile", startfile or MagicMock(), create=True
)
platform = patch.object(file_reveal.sys, "platform", "win32")
return select, start, platform
def test_windows_selects_through_the_shell_api_without_a_process(self):
# A comma path splits Explorer's own command line (security review H1);
# the shell API takes the path as one value, unchanged.
path = self.root / "a," / "b,c.png"
path.parent.mkdir()
path.write_bytes(b"png")
select, start, platform = self.windows(selected=True)
with select, start as startfile, platform:
self.reveal(path)
self.assertEqual(dict(select.shell.calls)["ILCreateFromPathW"], (str(path),))
startfile.assert_not_called()
self.popen.assert_not_called()
def test_windows_falls_back_to_opening_the_folder(self):
select, start, platform = self.windows(selected=False)
with select, start as startfile, platform:
self.reveal()
self.assertEqual(
dict(select.shell.calls)["ILCreateFromPathW"], (str(self.path),)
)
startfile.assert_called_once_with(str(self.path.parent), "explore")
self.popen.assert_not_called()
def test_windows_folder_start_failure_raises(self):
select, start, platform = self.windows(
selected=False, startfile=MagicMock(side_effect=OSError("denied"))
)
with select, start, platform, self.assertRaises(OSError):
self.reveal()
self.popen.assert_not_called()
def test_windows_without_startfile_is_unavailable(self):
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.sys, "platform", "win32"),
patch.object(file_reveal.os, "startfile", None, create=True),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_macos_reveals_with_the_system_open(self):
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value="/usr/bin/open"),
self.which({"open": "/planted/open"}),
):
self.reveal()
self.assertEqual(self.argv(), ["/usr/bin/open", "-R", str(self.path)])
self.assert_detached()
self.popen.return_value.wait.assert_not_called()
self.reaped.assert_called_once_with(self.popen.return_value)
def test_macos_open_is_the_fixed_system_path(self):
self.assertEqual(file_reveal.MACOS_OPEN, "/usr/bin/open")
with patch.object(file_reveal.os.path, "isfile", return_value=False):
self.assertIsNone(file_reveal._macos_open())
with (
patch.object(file_reveal.os.path, "isfile", return_value=True),
patch.object(file_reveal.os, "access", return_value=True),
):
self.assertEqual(file_reveal._macos_open(), "/usr/bin/open")
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value=None),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_path_lookup_never_counts(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "./dbus-send", "xdg-open": "bin/xdg-open"}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_posix_opener_lookup_does_not_depend_on_the_host_path_rules(self):
# Python 3.13 on Windows no longer treats "/usr/bin/x" as absolute. The
# Linux and macOS openers are POSIX paths whatever host runs the code,
# so a host os.path with those rules must not reject them.
windows_313 = SimpleNamespace(isabs=lambda value: False)
with (
patch.object(file_reveal.os, "path", windows_313),
self.which({"xdg-open": "/usr/bin/xdg-open", "rel": "bin/rel"}),
):
self.assertEqual(file_reveal._executable("xdg-open"), "/usr/bin/xdg-open")
self.assertIsNone(file_reveal._executable("rel"))
def test_children_get_a_minimal_environment(self):
inherited = {
"PATH": "/venv/bin:/usr/bin",
"PYTHONPATH": "/comfy",
"LD_LIBRARY_PATH": "/venv/lib",
"VIRTUAL_ENV": "/venv",
"CIVITAI_API_KEY": "secret",
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
# What a desktop opener can need (final review m1): the X11
# cookie under GDM/SDDM, the session and the user's mimeapps.
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
}
with patch.dict(os.environ, inherited, clear=True):
env = file_reveal._child_env()
self.assertEqual(
env,
{
"PATH": file_reveal.SYSTEM_PATH,
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
},
)
self.assertNotIn("/venv", file_reveal.SYSTEM_PATH)
def test_linux_asks_the_file_manager_service_and_waits_for_its_reply(self):
self.popen.return_value.wait.return_value = 0
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/x"}),
):
self.reveal()
self.assertEqual(
self.argv(),
[
"/usr/bin/dbus-send",
"--session",
"--print-reply",
"--reply-timeout=2000",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:{self.path.as_uri()}",
"string:",
],
)
self.assertEqual(self.popen.call_count, 1)
self.popen.return_value.wait.assert_called_once_with(
timeout=file_reveal.DBUS_WAIT
)
self.assert_detached()
self.reaped.assert_not_called()
def test_linux_falls_back_to_xdg_open_on_the_folder(self):
both = {"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/usr/bin/xdg-open"}
cases = {
"missing": ({"xdg-open": "/usr/bin/xdg-open"}, None),
"no reply": (both, 1),
"start error": (both, OSError("no bus")),
"hung": (both, subprocess.TimeoutExpired("dbus-send", 3)),
}
for name, (available, outcome) in cases.items():
with self.subTest(name):
self.popen.reset_mock(side_effect=True, return_value=True)
self.reaped.reset_mock()
dbus = MagicMock()
if isinstance(outcome, OSError):
self.popen.side_effect = [outcome, MagicMock()]
elif isinstance(outcome, subprocess.TimeoutExpired):
dbus.wait.side_effect = [outcome, None]
self.popen.side_effect = [dbus, MagicMock()]
else:
self.popen.return_value.wait.return_value = outcome
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which(available),
):
self.reveal()
self.assertEqual(
self.popen.call_args.args[0],
["/usr/bin/xdg-open", str(self.path.parent)],
)
self.assert_detached(self.popen.call_count - 1)
self.reaped.assert_called_once()
if name == "hung":
dbus.kill.assert_called_once_with()
def test_linux_without_any_opener_is_unavailable(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_paths_are_refused(self):
with self.assertRaises(ValueError):
reveal_in_file_manager(Path("sub/a.png"), self.root)
self.popen.assert_not_called()
def test_containment_is_rechecked_just_before_launching(self):
# A parent swapped for a symlink after the route resolved the path
# (security review L4) must not open a folder outside the root.
outside = self.root.parent / "elsewhere"
outside.mkdir()
(outside / "a.png").write_bytes(b"png")
(self.root / "sub" / "a.png").unlink()
(self.root / "sub").rmdir()
try:
os.symlink(outside, self.root / "sub", target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("symlinks unavailable")
missing = self.root / "gone.png"
folder = self.root / "folder.png"
folder.mkdir()
for path in (self.path, missing, folder):
for platform in ("linux", "darwin", "win32"):
with (
self.subTest(path=path.name, platform=platform),
patch.object(file_reveal.sys, "platform", platform),
patch.object(file_reveal, "_windows_libraries") as shell,
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal(path)
shell.assert_not_called()
self.popen.assert_not_called()
def test_a_path_on_another_drive_is_not_contained(self):
# Windows: commonpath raises ValueError across drives.
with (
patch.object(file_reveal.os.path, "commonpath", side_effect=ValueError),
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal()
self.popen.assert_not_called()
class TestReaping(unittest.TestCase):
def test_reap_waits_on_a_daemon_thread(self):
process = MagicMock()
with patch.object(file_reveal.threading, "Thread") as thread:
file_reveal._reap(process)
thread.assert_called_once()
kwargs = thread.call_args.kwargs
self.assertIs(kwargs["target"], process.wait)
self.assertIs(kwargs["daemon"], True)
thread.return_value.start.assert_called_once_with()
def test_reveal_runs_on_its_own_single_worker(self):
executor = file_reveal.reveal_executor()
self.assertIs(file_reveal.reveal_executor(), executor)
self.assertEqual(executor._max_workers, 1)
name = executor.submit(lambda: file_reveal.threading.current_thread().name)
self.assertTrue(name.result(timeout=5).startswith("pm-reveal"))
class FakeShell:
"""ole32 and shell32 stand-ins recording the shell calls (H1)."""
def __init__(self, init=0, pidl=1234, select=0):
self.calls = []
self.ole32 = types.SimpleNamespace(
CoInitializeEx=self.fn("CoInitializeEx", init),
CoUninitialize=self.fn("CoUninitialize", None),
)
self.shell32 = types.SimpleNamespace(
ILCreateFromPathW=self.fn("ILCreateFromPathW", pidl),
SHOpenFolderAndSelectItems=self.fn("SHOpenFolderAndSelectItems", select),
ILFree=self.fn("ILFree", None),
)
def fn(self, name, result):
def call(*args):
self.calls.append((name, args))
if isinstance(result, Exception):
raise result
return result
return MagicMock(side_effect=call)
def names(self):
return [name for name, _ in self.calls]
class TestWindowsReveal(unittest.TestCase):
"""The Windows opener with the shell libraries and os.startfile faked.
The folder fallback is recorded in the same call list, so its place
inside the COM bracket is visible (final review m2)."""
def reveal(self, shell, path=r"C:\out\a,\b.png"):
def startfile(*args):
shell.calls.append(("startfile", args))
with (
patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
),
patch.object(
file_reveal.os,
"startfile",
MagicMock(side_effect=startfile),
create=True,
),
):
file_reveal._reveal_windows(Path(path))
return shell
def test_selects_the_item_and_frees_everything(self):
path = r"C:\out\a,\Windows\System32\calc.exe,b\x.png"
shell = self.reveal(FakeShell(), path)
self.assertEqual(
shell.names(),
[
"CoInitializeEx",
"ILCreateFromPathW",
"SHOpenFolderAndSelectItems",
"ILFree",
"CoUninitialize",
],
)
self.assertEqual(dict(shell.calls)["ILCreateFromPathW"], (str(Path(path)),))
self.assertEqual(
dict(shell.calls)["SHOpenFolderAndSelectItems"], (1234, 0, None, 0)
)
self.assertEqual(dict(shell.calls)["ILFree"], (1234,))
def test_declares_the_shell_signatures(self):
shell = self.reveal(FakeShell())
create = shell.shell32.ILCreateFromPathW
self.assertEqual(create.argtypes, [ctypes.c_wchar_p])
self.assertIs(create.restype, ctypes.c_void_p)
open_select = shell.shell32.SHOpenFolderAndSelectItems
self.assertEqual(
open_select.argtypes,
[ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulong],
)
self.assertIs(open_select.restype, ctypes.c_long)
self.assertEqual(shell.shell32.ILFree.argtypes, [ctypes.c_void_p])
self.assertEqual(
shell.ole32.CoInitializeEx.argtypes, [ctypes.c_void_p, ctypes.c_ulong]
)
self.assertIs(shell.ole32.CoInitializeEx.restype, ctypes.c_long)
def test_already_initialized_com_is_used_and_left_alone(self):
shell = self.reveal(FakeShell(init=file_reveal.RPC_E_CHANGED_MODE))
self.assertIn("SHOpenFolderAndSelectItems", shell.names())
self.assertNotIn("CoUninitialize", shell.names())
self.assertNotIn("startfile", shell.names())
shell = self.reveal(FakeShell(init=file_reveal.S_FALSE))
self.assertNotIn("startfile", shell.names())
self.assertEqual(shell.names()[-1], "CoUninitialize")
def test_the_folder_fallback_runs_inside_com(self):
# ShellExecute callers should initialize COM first (final review m2).
for name, shell in {
"no pidl": FakeShell(pidl=None),
"select failed": FakeShell(select=-2147467259),
"raises": FakeShell(select=OSError("access violation")),
}.items():
with self.subTest(name):
names = self.reveal(shell).names()
self.assertEqual(names[0], "CoInitializeEx")
self.assertEqual(names[-2:], ["startfile", "CoUninitialize"])
if name != "no pidl":
self.assertIn("ILFree", names)
def test_failed_com_still_opens_the_folder_without_balancing(self):
for init in (-2147024882, OSError("no ole32 export")):
with self.subTest(init=init):
names = self.reveal(FakeShell(init=init)).names()
self.assertEqual(names, ["CoInitializeEx", "startfile"])
def test_missing_libraries_open_the_folder(self):
startfile = MagicMock()
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.os, "startfile", startfile, create=True),
):
file_reveal._reveal_windows(Path(r"C:\out\a.png"))
startfile.assert_called_once_with(str(Path(r"C:\out\a.png").parent), "explore")
def test_libraries_are_unavailable_off_windows(self):
if sys.platform == "win32":
self.skipTest("loads the real shell libraries on Windows")
with self.assertRaises((AttributeError, OSError)):
file_reveal._windows_libraries()
@unittest.skipUnless(sys.platform == "win32", "the real Windows shell libraries")
class TestRealWindowsShell(unittest.TestCase):
"""Final review I1: the declared signatures against the real ole32 and
shell32, without SHOpenFolderAndSelectItems (no window opens on CI)."""
def setUp(self):
self.ole32, self.shell32 = file_reveal._windows_libraries()
file_reveal._declare_shell_signatures(self.ole32, self.shell32)
def test_a_pidl_is_created_for_a_real_file_and_freed(self):
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as folder:
path = Path(folder) / "a,b.png"
path.write_bytes(b"png") # write_bytes closes its handle
pidl = self.shell32.ILCreateFromPathW(str(path))
self.assertIsInstance(pidl, int)
self.assertNotEqual(pidl, 0)
self.shell32.ILFree(pidl)
self.assertIsNone(self.shell32.ILCreateFromPathW(str(path) + ".gone"))
path.unlink()
def probe(self):
"""CoInitializeEx's answer on this thread, balanced at once."""
result = self.ole32.CoInitializeEx(
None,
file_reveal.COINIT_APARTMENTTHREADED | file_reveal.COINIT_DISABLE_OLE1DDE,
)
if result in (file_reveal.S_OK, file_reveal.S_FALSE):
self.ole32.CoUninitialize()
return result
def test_com_is_entered_and_balanced_through_the_module_helper(self):
before = self.probe()
with file_reveal._com_apartment(self.ole32) as ready:
self.assertIs(ready, True)
# An unbalanced bracket would leave COM initialized: S_FALSE after S_OK.
self.assertEqual(self.probe(), before)
class TestRevealRoute(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
self.opened, self.roots, self.threads = [], [], []
self.outcome = None
def opener(path, root):
self.threads.append(file_reveal.threading.current_thread().name)
if self.outcome is not None:
raise self.outcome
self.opened.append(path)
self.roots.append(root)
self.api._reveal_opener = opener
self.api._reveal_gate = file_reveal.RevealGate(cooldown=0)
return app
def origin(self):
return str(self.client.make_url("/").origin())
def scope(self, index=0):
roots = self.api._get_all_output_dirs()
return self.api._gallery_root_scope(roots[index], index)
async def post(self, body, **request_headers):
# Like the workspace's fetch: a same-origin JSON POST.
sent = {"Origin": self.origin(), **request_headers}
sent = {key: value for key, value in sent.items() if value is not None}
response = await self.client.post(REVEAL, json=body, headers=sent)
return response.status, await response.json()
def body(self, path="sub/a.png", **changes):
return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes}
async def test_loopback_request_opens_the_resolved_file(self):
target = make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body())
self.assertEqual((status, data), (200, {"success": True}))
self.assertEqual(self.opened, [Path(os.path.realpath(target))])
self.assertEqual(self.roots, [self.output_dir])
# Its own worker, never ComfyUI's shared default executor (L1).
self.assertTrue(self.threads[0].startswith("pm-reveal"), self.threads)
async def test_target_changed_before_launch_is_404(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = file_reveal.RevealTargetChanged()
status, data = await self.post(self.body())
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
async def test_non_loopback_remote_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
with patch.object(
web.BaseRequest, "remote", new_callable=PropertyMock
) as remote:
remote.return_value = "192.168.1.20"
status, data = await self.post(self.body())
self.assertEqual(status, 403)
self.assertEqual(data["code"], "not-local")
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_loopback_with_a_proxy_header_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
for name in PROXY_HEADERS:
with self.subTest(header=name):
status, data = await self.post(self.body(), **{name: "127.0.0.1"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), Host="evil.example:8188")
self.assertEqual((status, data["code"]), (403, "not-local"))
self.assertEqual(self.opened, [])
async def test_missing_or_foreign_origin_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
port = self.client.make_url("/").port
for origin in (None, f"http://127.0.0.1:{port + 1}", "null"):
with self.subTest(origin=origin):
status, data = await self.post(self.body(), Origin=origin)
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), **{"Sec-Fetch-Site": "same-site"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, _ = await self.post(self.body(), **{"Sec-Fetch-Site": "same-origin"})
self.assertEqual(status, 200)
self.assertEqual(len(self.opened), 1)
async def test_only_json_bodies_are_accepted(self):
# text/plain would be a CORS "simple" request with no preflight (L2).
make_png(self.output_dir / "sub" / "a.png")
body = json.dumps(self.body())
for content_type in ("text/plain", "application/x-www-form-urlencoded", None):
with self.subTest(content_type=content_type):
sent = {"Origin": self.origin()}
if content_type:
sent["Content-Type"] = content_type
response = await self.client.post(
REVEAL, data=body.encode(), headers=sent
)
self.assertEqual(response.status, 415)
data = await response.json()
self.assertEqual(
(data["success"], data["code"]), (False, "unsupported-media-type")
)
self.assertEqual(self.opened, [])
async def test_a_reveal_in_progress_or_cooling_down_is_busy(self):
make_png(self.output_dir / "sub" / "a.png")
gate = self.api._reveal_gate
self.assertTrue(gate.try_enter())
status, data = await self.post(self.body())
self.assertEqual(status, 429)
self.assertEqual(data, {**data, "success": False, "code": "busy"})
gate.leave()
now = [50.0]
self.api._reveal_gate = file_reveal.RevealGate(
cooldown=1.0, clock=lambda: now[0]
)
statuses = [(await self.post(self.body()))[0] for _ in range(2)]
now[0] += 1.0
statuses.append((await self.post(self.body()))[0])
self.assertEqual(statuses, [200, 429, 200])
self.assertEqual(len(self.opened), 2)
async def test_a_failed_reveal_releases_the_gate(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError("cannot start")
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
self.outcome = None
status, _ = await self.post(self.body())
self.assertEqual(status, 200)
async def test_default_gate_is_one_per_process_with_a_cooldown(self):
gate = type(self.api)._reveal_gate
self.assertIsInstance(gate, file_reveal.RevealGate)
self.assertEqual(gate.cooldown, file_reveal.REVEAL_COOLDOWN)
self.assertGreaterEqual(file_reveal.REVEAL_COOLDOWN, 1.0)
async def test_resolution_errors_are_404_and_never_log_the_path(self):
# ENAMETOOLONG and friends must not escape as a non-JSON 500 (L3).
make_png(self.output_dir / "sub" / "a.png")
secret = "sub/" + "n" * 300 + "/a.png"
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
self.api.logger.debug("start")
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
with patch.object(
type(self.api),
"_reveal_path",
staticmethod(MagicMock(side_effect=OSError(36, "too long", secret))),
):
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertIs(data["success"], False)
for record in logs.records:
if record.levelno >= logging.WARNING:
self.assertNotIn("n" * 300, record.getMessage())
self.assertEqual(self.opened, [])
async def test_start_failure_never_logs_the_path_at_error_level(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError(2, "No such file", str(self.output_dir / "sub"))
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
errors = [r for r in logs.records if r.levelno >= logging.ERROR]
self.assertTrue(errors)
for record in errors:
self.assertNotIn(str(self.output_dir), record.getMessage())
self.assertIsNone(record.exc_info)
async def test_changed_or_missing_scope_is_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body(root_scope="0" * 64))
self.assertEqual((status, data["code"]), (409, "source-changed"))
status, data = await self.post(self.body(root_index=3))
self.assertEqual((status, data["code"]), (409, "source-changed"))
for scope in (None, "ABC", 7, "g" * 64):
with self.subTest(scope=scope):
status, _ = await self.post(self.body(root_scope=scope))
self.assertEqual(status, 400)
body = self.body()
del body["root_scope"]
status, _ = await self.post(body)
self.assertEqual(status, 400)
self.assertEqual(self.opened, [])
async def test_body_types_are_strict(self):
make_png(self.output_dir / "sub" / "a.png")
for changes in (
{"root_index": True},
{"root_index": "0"},
{"root_index": -1},
{"root_index": 0.0},
{"path": 5},
{"path": ""},
{"path": "a" * 5000 + ".png"},
{"path": "sub/a\0.png"},
):
with self.subTest(changes=changes):
status, data = await self.post(self.body(**changes))
self.assertEqual(status, 400, data)
response = await self.client.post(
REVEAL,
data="[1]",
headers={"Origin": self.origin(), "Content-Type": "application/json"},
)
self.assertEqual(response.status, 400)
self.assertEqual(self.opened, [])
async def test_traversal_and_absolute_paths_are_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
outside = make_png(self.comfy_dir / "outside.png")
for path in (
"../outside.png",
"sub/../../outside.png",
"sub\\..\\..\\outside.png",
str(outside),
"/" + outside.name,
"C:/outside.png",
"C:outside.png",
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, 400, data)
self.assertEqual(self.opened, [])
async def test_symlink_out_of_the_root_is_refused(self):
outside = make_png(self.comfy_dir / "outside.png")
self._make_symlink(outside, self.output_dir / "link.png")
status, _ = await self.post(self.body(path="link.png"))
self.assertIn(status, (403, 404))
self.assertEqual(self.opened, [])
async def test_missing_non_media_and_non_regular_files(self):
(self.output_dir / "folder.png").mkdir()
(self.output_dir / "notes.txt").write_text("x")
for path, expected in (
("sub/missing.png", 404),
("folder.png", 404),
("notes.txt", 403),
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, expected, data)
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_unavailable_opener_is_501_and_start_failure_500(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OpenerUnavailable()
status, data = await self.post(self.body())
self.assertEqual(status, 501)
self.assertEqual(data, {**data, "success": False, "code": "unavailable"})
self.outcome = OSError("cannot start")
status, data = await self.post(self.body())
self.assertEqual(status, 500)
self.assertIs(data["success"], False)
self.assertNotIn("cannot start", data["error"])
async def test_default_opener_is_the_platform_file_manager(self):
self.assertIs(type(self.api)._reveal_opener, file_reveal.reveal_in_file_manager)
if __name__ == "__main__":
unittest.main()
+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__":
+3 -1
View File
@@ -254,7 +254,9 @@ class TestOutcomeMessages(unittest.TestCase):
def test_candidates_and_absence_keep_their_status_lines(self):
graph = _runtime()
graph["7"] = _encode("positive")
graph["5"] = _ksampler()
# A second sampler with its own prompt (#249: identical ones resolve).
graph["8"] = _encode("other positive")
graph["5"] = _ksampler("8")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
+2 -1
View File
@@ -36,7 +36,8 @@ LIBRARY = {
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
}
NODE_TIMEOUT_SECONDS = 120
+17 -1
View File
@@ -77,7 +77,8 @@ def encode(read, *, prompts=None, file=None, library=None):
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
},
)
@@ -663,6 +664,21 @@ class TestInspectionResponse(unittest.TestCase):
for key in ("positive_id", "pair_id", "category", "title"):
self.assertNotIn(key, result["library"])
self.assertEqual(result["library"]["tags"], ["valid"])
# An unknown or non-string scope is dropped, never passed through.
for bad in ("recipe", 3, ["pair"]):
result = json.loads(
encode(
None,
library={
"status": "linked",
"category_scope": bad,
"tags_scope": bad,
"tags": [],
},
)
)
self.assertNotIn("category_scope", result["library"])
self.assertNotIn("tags_scope", result["library"])
result = json.loads(
encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]})
)
+133
View File
@@ -92,6 +92,17 @@ class TestLibrary(unittest.TestCase):
self.db.list_library_images(positive, pair_id=unrelated)
self.assertIsNone(self.db.get_library_positive(9999))
def test_detail_variants_carry_their_pair_run_count(self):
counted = self.db.save_prompt_pair("Cat", "Blur")
fresh = self.db.save_prompt_pair("Cat", "Noise")
self.db.record_prompt_pair_use(counted, 3)
positive = self.db.get_prompt_pair(counted)["positive_prompt_id"]
variants = {
row["id"]: row for row in self.db.get_library_positive(positive)["variants"]
}
self.assertEqual(variants[counted]["run_count"], 3)
self.assertEqual(variants[fresh]["run_count"], 0)
def test_options_include_active_pair_categories_not_negative_only_or_archived(self):
self.save("Legacy", category="Legacy category")
pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category")
@@ -487,6 +498,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 +1704,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 + "…")
+24 -1
View File
@@ -51,11 +51,34 @@ class TestPairImageLinking(unittest.TestCase):
self.assertEqual(pairs[0]["image_count"], 1)
self.assertEqual(pairs[0]["run_count"], 0)
def test_parallel_identical_branches_create_their_shared_pair(self):
# #249: two samplers on one positive and one negative are one certain pair.
meta = metadata()
meta["prompt"]["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
}
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
pairs = self.db.list_prompt_pairs()["pairs"]
self.assertEqual(
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
)
self.assertEqual(pairs[0]["image_count"], 1)
self.assertEqual(pairs[0]["run_count"], 0)
def test_stale_context_or_ambiguous_metadata_does_not_create_pair(self):
meta = metadata(positive="dog")
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
meta = metadata()
meta["prompt"]["4"] = meta["prompt"]["3"]
# Two samplers with different positives (#249: identical ones resolve).
meta["prompt"]["5"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": "dog"},
}
meta["prompt"]["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
self.handler.link_image_to_prompt(self.path + "2", {"id": self.pid}, meta)
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
+70 -7
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,71 @@ 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)}
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):
# #249: every branch resolves to one exact pair, so the pair is ingested.
g = sampled_graph()
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("multi.png", g), "added")
self.assertEqual(sorted(self._texts()), ["blur", "cat"])
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
"ambiguous",
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
)
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"}
+23 -4
View File
@@ -54,13 +54,30 @@ class TestPromptPairResolver(unittest.TestCase):
self.assertEqual(result["positive"]["text"], "portrait")
self.assertIsNone(result["negative"])
def test_multiple_samplers_are_ambiguous_even_when_texts_match(self):
def test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair(self):
source = graph()
source["4"] = {
"class_type": "KSamplerAdvanced",
"inputs": source["3"]["inputs"],
}
result = resolve_prompt_pair(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(
(result["positive"]["text"], result["negative"]["text"]),
("portrait", "blurry"),
)
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
self.assertEqual(len(result["candidates"]), 2)
def test_multiple_samplers_with_different_texts_are_ambiguous(self):
source = graph()
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "other"}}
source["4"] = {
"class_type": "KSamplerAdvanced",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
result = resolve_prompt_pair(source)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(len(result["candidates"]), 2)
self.assertIsNone(result["positive"])
@@ -2253,12 +2270,14 @@ class TestDetailerStages(unittest.TestCase):
self.assertEqual(_sides(result), (parity.DETAILER_POS, parity.NEG))
self.assertEqual(result["sampler_id"], "50")
def test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor(self):
def test_raw_and_detailed_saves_of_one_prompt_share_it_without_an_anchor(self):
source = _detailed(same_prompt=True)
source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
result = _inspect(source)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
# #249: both saves used one pair, so it resolves with no sampler named.
self.assertEqual(result["state"], "resolved")
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
self.assertEqual(
sorted(item["sampler_id"] for item in result["candidates"]), ["3", "50"]
)
+652
View File
@@ -0,0 +1,652 @@
"""Saved views (D16): the persisted store, its restore schema and the API."""
import json
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
try:
from test_api_images import ImageAPITestCase
from open_handles import assert_closed
except ImportError:
from tests.test_api_images import ImageAPITestCase
from tests.open_handles import assert_closed
from database.models import PromptModel
from database.operations import PromptDatabase
from database.saved_views import (
MAX_STATE_BYTES,
TABLE,
SavedViewInvalid,
encode_state,
migrate_saved_views,
normalize_state,
saved_views_schema_error,
validate_name,
)
from utils.hashing import generate_prompt_hash
URL = "/prompt_manager/saved-views"
IMAGES_STATE = {
"q": "cat",
"category": "Animals",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": ["fluffy"],
"favorites": False,
}
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
IMAGES_DEFAULTS = {
"q": "",
"category": "",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": [],
"favorites": False,
}
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
# A row saved by an older build: no favorites key, a retired key, a sort
# that is no longer offered and an over-long folder.
OLD_IMAGES_STATE = json.dumps(
{
"q": "sunset",
"category": "",
"sort": "shuffle",
"source": "all",
"folder": "x" * 2000,
"media": "image",
"tags": ["golden"],
"layout": "grid",
}
)
class TestSavedViewsApi(ImageAPITestCase):
async def send(self, method, path="", body=None, **kwargs):
if body is not None:
kwargs["json"] = body
response = await self.client.request(method, URL + path, **kwargs)
return response, await response.json()
async def create(self, page="images", name="Cats", state=None):
state = dict(
IMAGES_STATE if page == "images" else LIBRARY_STATE, **(state or {})
)
response, data = await self.send(
"POST", body={"page": page, "name": name, "state": state}
)
self.assertEqual(response.status, 201, data)
return data["view"]
async def test_create_list_rename_delete_round_trip(self):
response, data = await self.send("GET")
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "views": []})
first = await self.create(name=" Cats ")
self.assertEqual(
first,
{
"id": first["id"],
"page": "images",
"name": "Cats",
"state": IMAGES_STATE,
},
)
second = await self.create(page="library", name="Best", state={})
_, data = await self.send("GET")
self.assertTrue(data["success"])
self.assertEqual(data["views"], [first, second])
response, data = await self.send(
"PATCH", f"/{first['id']}", {"name": " Kittens "}
)
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "view": {**first, "name": "Kittens"}})
_, listed = await self.send("GET")
# Renaming keeps the view's place in the list.
self.assertEqual(
[view["name"] for view in listed["views"]], ["Kittens", "Best"]
)
response, data = await self.send("DELETE", f"/{first['id']}")
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "id": first["id"]})
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [second])
for method, body in (("PATCH", {"name": "Again"}), ("DELETE", None)):
response, data = await self.send(method, f"/{first['id']}", body)
self.assertEqual(response.status, 404)
self.assertFalse(data["success"])
self.assertIn("error", data)
async def test_views_are_ordered_by_position_and_ids_are_not_reused(self):
views = [await self.create(name=f"View {n}") for n in range(3)]
await self.send("DELETE", f"/{views[2]['id']}")
later = await self.create(name="Later")
self.assertGreater(later["id"], views[2]["id"])
with self.api.db.model.get_connection() as conn:
conn.execute(
f"UPDATE {TABLE} SET position = -1 WHERE id = ?", (views[1]["id"],)
)
_, listed = await self.send("GET")
self.assertEqual(
[view["name"] for view in listed["views"]], ["View 1", "View 0", "Later"]
)
async def test_create_rejects_invalid_bodies(self):
good = {"page": "images", "name": "Cats", "state": IMAGES_STATE}
cases = [
["not an object"],
{"page": "images", "name": "Cats"},
{**good, "extra": 1},
{**good, "page": "history"},
{**good, "page": 1},
{**good, "name": ""},
{**good, "name": " "},
{**good, "name": "x" * 101},
{**good, "name": 7},
{**good, "name": "bad\nname"},
{**good, "state": "q=cat"},
{**good, "state": None},
{**good, "state": {**IMAGES_STATE, "unknown": ""}},
{**good, "state": {k: v for k, v in IMAGES_STATE.items() if k != "q"}},
{**good, "state": {**IMAGES_STATE, "q": 5}},
{**good, "state": {**IMAGES_STATE, "q": "x" * 501}},
{**good, "state": {**IMAGES_STATE, "category": "x" * 101}},
{**good, "state": {**IMAGES_STATE, "sort": "random"}},
{**good, "state": {**IMAGES_STATE, "media": "audio"}},
{**good, "state": {**IMAGES_STATE, "source": "-1"}},
{**good, "state": {**IMAGES_STATE, "source": 0}},
{**good, "state": {**IMAGES_STATE, "folder": None}},
{**good, "state": {**IMAGES_STATE, "folder": "x" * 1025}},
{**good, "state": {**IMAGES_STATE, "tags": "cat"}},
{**good, "state": {**IMAGES_STATE, "tags": [1]}},
{**good, "state": {**IMAGES_STATE, "tags": [" "]}},
{**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}},
{**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}},
{**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}},
# Favorites (the Favorites page's filter) is a JSON boolean.
{**good, "state": {**IMAGES_STATE, "favorites": "true"}},
{**good, "state": {**IMAGES_STATE, "favorites": 1}},
{**good, "state": {**IMAGES_STATE, "favorites": None}},
{
**good,
"state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"},
},
# Library has no source, folder or media, and its own sorts.
{"page": "library", "name": "L", "state": IMAGES_STATE},
{
"page": "library",
"name": "L",
"state": {**LIBRARY_STATE, "sort": "name-desc"},
},
]
for body in cases:
with self.subTest(body=body):
response, data = await self.send("POST", body=body)
self.assertEqual(response.status, 400)
self.assertFalse(data["success"])
self.assertIsInstance(data["error"], str)
response = await self.client.post(URL, data="{not json")
self.assertEqual(response.status, 400)
self.assertFalse((await response.json())["success"])
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [])
async def test_encoded_state_is_capped(self):
# Each value is within its own limit; together they pass 4 KB.
state = {
**IMAGES_STATE,
"q": "é" * 500,
"folder": "é" * 1024,
"tags": ["é" * 50 for _ in range(20)],
}
encoded = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
self.assertGreater(len(encoded.encode("utf-8")), MAX_STATE_BYTES)
response, data = await self.send(
"POST", body={"page": "images", "name": "Big", "state": state}
)
self.assertEqual(response.status, 400)
self.assertIn("4 KB", data["error"])
async def test_every_documented_value_is_accepted(self):
for sort in ("newest", "oldest", "name", "name-desc", "rating"):
for media in ("all", "image", "video"):
await self.create(state={"sort": sort, "media": media, "source": "3"})
favorite = await self.create(state={"favorites": True})
self.assertIs(favorite["state"]["favorites"], True)
for sort in ("newest", "oldest", "name", "rating"):
await self.create(page="library", state={"sort": sort, "tags": ["a b"]})
_, listed = await self.send("GET")
self.assertEqual(len(listed["views"]), 20)
async def test_rename_and_delete_validate_id_and_body(self):
view = await self.create()
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
for method, body in (("PATCH", {"name": "X"}), ("DELETE", None)):
with self.subTest(path=path, method=method):
response, data = await self.send(method, path, body)
self.assertEqual(response.status, 400)
self.assertFalse(data["success"])
for body in ({}, {"name": ""}, {"name": "X", "page": "library"}, ["X"]):
with self.subTest(body=body):
response, _ = await self.send("PATCH", f"/{view['id']}", body)
self.assertEqual(response.status, 400)
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [view])
async def test_database_failure_is_a_500_envelope(self):
def broken():
raise sqlite3.OperationalError("disk I/O error")
self.api.db.list_saved_views = broken
with self.assertLogs("prompt_manager.api", level="ERROR"):
response, data = await self.send("GET")
self.assertEqual(response.status, 500)
self.assertEqual(
data, {"success": False, "error": "Saved view operation failed"}
)
async def test_internal_value_errors_are_a_generic_500(self):
# Only the request's own validation is a 400; a ValueError or
# TypeError raised by the database layer is a server fault (M4).
for error in (
ValueError("secret parser detail"),
TypeError("secret type detail"),
json.JSONDecodeError("secret json detail", "{", 0),
):
def broken(*_args, error=error):
raise error
for method, path, body, attr in (
("GET", "", None, "list_saved_views"),
(
"POST",
"",
{"page": "library", "name": "L", "state": LIBRARY_STATE},
"create_saved_view",
),
("PATCH", "/1", {"name": "X"}, "rename_saved_view"),
("DELETE", "/1", None, "delete_saved_view"),
):
with self.subTest(error=type(error).__name__, method=method):
setattr(self.api.db, attr, broken)
try:
with self.assertLogs("prompt_manager.api", level="ERROR"):
response, data = await self.send(method, path, body)
finally:
delattr(self.api.db, attr)
self.assertEqual(response.status, 500)
self.assertEqual(
data,
{"success": False, "error": "Saved view operation failed"},
)
async def test_a_corrupt_state_is_skipped_by_the_listing(self):
kept = await self.create(page="library", name="Kept", state={})
with self.api.db.model.get_connection() as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', 'Broken', '{', 0, 'x', 'x')"
)
response, data = await self.send("GET")
self.assertEqual(response.status, 200, data)
self.assertEqual(data["views"], [kept])
# Hidden from the listing, it is not found by rename either.
broken_id = kept["id"] + 1
response, _ = await self.send("PATCH", f"/{broken_id}", {"name": "X"})
self.assertEqual(response.status, 404)
response, _ = await self.send("DELETE", f"/{broken_id}")
self.assertEqual(response.status, 200)
async def test_a_row_saved_under_older_rules_lists_normalized(self):
stale = json.dumps(
{
"q": "cat",
"sort": "random",
"media": "video",
"tags": ["ok", 7],
"retired": True,
}
)
with self.api.db.model.get_connection() as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', ' Old\tview ', ?, 0, 'x', 'x')",
(stale,),
)
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('library', '', '{}', 1, 'x', 'x')"
)
response, data = await self.send("GET")
self.assertEqual(response.status, 200, data)
images, library = data["views"]
self.assertEqual(images["name"], "Oldview")
self.assertEqual(
images["state"],
{**IMAGES_DEFAULTS, "q": "cat", "media": "video"},
)
self.assertEqual(library["name"], "Saved view")
self.assertEqual(library["state"], LIBRARY_DEFAULTS)
# A stale view still renames and deletes.
response, data = await self.send(
"PATCH", f"/{images['id']}", {"name": "Renamed"}
)
self.assertEqual(response.status, 200, data)
self.assertEqual(data["view"]["state"], images["state"])
class TestSavedViewsSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.path = os.path.join(self.tmpdir, "prompts.db")
self.db = PromptDatabase(self.path)
self.addCleanup(self._cleanup)
def _cleanup(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 _source(self, name="source.db", views=()):
"""A closed database file holding a prompt and the given views."""
path = os.path.join(self.tmpdir, name)
src = PromptDatabase(path)
try:
src.save_prompt(text="kept", prompt_hash=generate_prompt_hash("kept"))
for page, view_name, state in views:
src.create_saved_view(page, view_name, state)
finally:
src.close_all()
return path
def test_current_database_has_a_valid_table(self):
conn = self.db.model.get_connection()
self.assertIsNone(saved_views_schema_error(conn, allow_missing=False))
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
def test_migration_is_idempotent(self):
conn = self.db.model.get_connection()
self.db.create_saved_view("images", "Cats", IMAGES_STATE)
migrate_saved_views(conn)
self.assertEqual(len(self.db.list_saved_views()), 1)
def test_backup_restore_round_trip_keeps_saved_views(self):
self.db.create_saved_view("library", "Live", LIBRARY_STATE)
src_path = self._source(
views=[
("images", "Cats", IMAGES_STATE),
("library", "Best", LIBRARY_STATE),
]
)
# The views may still sit in the WAL; the backup must carry them.
backup = os.path.join(self.tmpdir, "backup.db")
reopened = PromptDatabase(src_path)
try:
self.assertTrue(reopened.model.backup_database(backup))
finally:
reopened.close_all()
previous = self.db.model.restore_from_file(backup)
self.assertEqual(
[
(view["page"], view["name"], view["state"])
for view in self.db.list_saved_views()
],
[("images", "Cats", IMAGES_STATE), ("library", "Best", LIBRARY_STATE)],
)
created = self.db.create_saved_view("images", "After", IMAGES_STATE)
self.assertEqual(self.db.list_saved_views()[-1], created)
with closing(sqlite3.connect(previous)) as conn:
self.assertEqual(
conn.execute(f"SELECT name FROM {TABLE}").fetchall(), [("Live",)]
)
def test_restore_old_backup_without_saved_views(self):
self.db.create_saved_view("images", "Live", IMAGES_STATE)
src = self._source()
with closing(sqlite3.connect(src)) as conn:
conn.execute(f"DROP TABLE {TABLE}")
conn.commit()
self.assertTrue(PromptModel.verify_database_file(src)[0])
ok, _ = PromptModel.verify_database_file(src, require_library_state=True)
self.assertFalse(ok)
self.db.model.restore_from_file(src)
self.assertEqual(self.db.list_saved_views(), [])
self.db.create_saved_view("library", "New", LIBRARY_STATE)
self.assertEqual(len(self.db.list_saved_views()), 1)
with closing(sqlite3.connect(src)) as conn: # the source is untouched
self.assertIsNone(
conn.execute(
"SELECT 1 FROM sqlite_master WHERE name=?", (TABLE,)
).fetchone()
)
def _tampered(self, *statements, views=(("images", "Cats", IMAGES_STATE),)):
src = self._source(
name=f"tampered{len(os.listdir(self.tmpdir))}.db", views=views
)
with closing(sqlite3.connect(src)) as conn:
for statement in statements:
conn.execute(statement)
conn.commit()
return src
def test_restore_rejects_tampered_saved_views(self):
cases = {
"definition": (
f"DROP TABLE {TABLE}",
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY, page TEXT, "
"name TEXT, state TEXT, position INTEGER, created_at TEXT, "
"updated_at TEXT)",
),
"index": (f"CREATE INDEX saved_views_extra ON {TABLE}(name)",),
"page": (
"PRAGMA ignore_check_constraints = ON",
f"UPDATE {TABLE} SET page = 'history'",
),
"long name": (f"UPDATE {TABLE} SET name = '{'x' * 401}'",),
"blob name": (f"UPDATE {TABLE} SET name = CAST('Cats' AS BLOB)",),
"state json": (f"UPDATE {TABLE} SET state = '{{'",),
"state array": (f"UPDATE {TABLE} SET state = '[]'",),
"state string": (f"UPDATE {TABLE} SET state = '\"q\"'",),
"state size": (
f"UPDATE {TABLE} SET state = '{'x' * (MAX_STATE_BYTES + 1)}'",
),
"position": (f"UPDATE {TABLE} SET position = 'first'",),
"timestamp": (f"UPDATE {TABLE} SET created_at = X'00'",),
}
for label, statements in cases.items():
with self.subTest(label=label):
src = self._tampered(*statements)
ok, reason = PromptModel.verify_database_file(src)
self.assertFalse(ok)
self.assertIn("Saved views", reason)
with self.assertRaises(ValueError):
self.db.model.restore_from_file(src)
def _insert_old_rows(self, path):
"""Rows written under older value rules, straight into the file."""
with closing(sqlite3.connect(path)) as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', 'Golden hour', ?, 5, 'x', 'x')",
(OLD_IMAGES_STATE,),
)
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
)
conn.commit()
def _assert_old_rows_normalized(self, views):
self.assertEqual(
[(view["page"], view["name"], view["state"]) for view in views],
[
(
"images",
"Golden hour",
{
**IMAGES_DEFAULTS,
"q": "sunset",
"media": "image",
"tags": ["golden"],
},
),
("library", "n" * 100, LIBRARY_DEFAULTS),
],
)
def test_a_database_with_rows_from_older_rules_still_opens(self):
self.db.close_all()
self._insert_old_rows(self.path)
PromptModel.reset_schema_cache()
reopened = PromptDatabase(self.path)
try:
self._assert_old_rows_normalized(reopened.list_saved_views())
finally:
reopened.close_all()
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
def test_restore_accepts_rows_from_older_rules(self):
src = self._source()
self._insert_old_rows(src)
ok, reason = PromptModel.verify_database_file(src, require_library_state=True)
self.assertTrue(ok, reason)
self.db.model.restore_from_file(src)
self._assert_old_rows_normalized(self.db.list_saved_views())
def _insert_corrupt_rows(self, path):
"""A valid view between two rows whose state is no JSON object."""
with closing(sqlite3.connect(path)) as conn:
ids = []
for name, state in (
("Broken", "{not json"),
("Kept", json.dumps(LIBRARY_STATE)),
("Array", "[1, 2]"),
):
ids.append(
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, "
"created_at, updated_at) VALUES ('library', ?, ?, 0, "
"'x', 'x')",
(name, state),
).lastrowid
)
conn.commit()
return ids
def test_startup_tolerates_a_corrupt_state_and_list_skips_it(self):
self.db.close_all()
broken, kept, array = self._insert_corrupt_rows(self.path)
PromptModel.reset_schema_cache()
with self.assertLogs("prompt_manager.database", level="WARNING") as logs:
reopened = PromptDatabase(self.path)
try:
self.assertEqual(
[(view["id"], view["name"]) for view in reopened.list_saved_views()],
[(kept, "Kept")],
)
finally:
reopened.close_all()
output = "\n".join(logs.output)
self.assertIn(f"id {broken}", output)
self.assertIn(f"id {array}", output)
self.assertNotIn("not json", output)
# The rows stay in place, untouched.
with closing(sqlite3.connect(self.path)) as conn:
self.assertEqual(
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 3
)
def test_restore_rejects_a_backup_with_a_corrupt_state(self):
src = self._source()
self._insert_corrupt_rows(src)
ok, reason = PromptModel.verify_database_file(src)
self.assertFalse(ok)
self.assertEqual(reason, "Saved views contains invalid values")
with self.assertRaises(ValueError):
self.db.model.restore_from_file(src)
def test_normalize_state_keeps_valid_values_and_defaults_the_rest(self):
self.assertEqual(normalize_state("images", IMAGES_STATE), IMAGES_STATE)
self.assertEqual(normalize_state("library", LIBRARY_STATE), LIBRARY_STATE)
self.assertEqual(
normalize_state("images", json.dumps(IMAGES_STATE)), IMAGES_STATE
)
for raw in (None, "{", "[]", '"q"', 7, ["q"], b"{}"):
with self.subTest(raw=raw):
self.assertEqual(normalize_state("library", raw), LIBRARY_DEFAULTS)
self.assertEqual(
normalize_state(
"images",
{
"q": "bad\x00",
"source": 3,
"favorites": "true",
"tags": [f"t{n}" for n in range(21)],
"folder": "kept",
},
),
{**IMAGES_DEFAULTS, "folder": "kept"},
)
self.assertEqual(normalize_state("history", {}), {})
def test_validation_errors_are_saved_view_invalid(self):
for call in (
lambda: validate_name(""),
lambda: validate_name(3),
lambda: encode_state("history", {}),
lambda: encode_state("library", {"q": ""}),
lambda: encode_state("library", {**LIBRARY_STATE, "sort": "x"}),
):
with self.assertRaises(SavedViewInvalid):
call()
self.assertTrue(issubclass(SavedViewInvalid, ValueError))
def test_migration_refuses_an_unsupported_table(self):
path = os.path.join(self.tmpdir, "foreign.db")
with closing(sqlite3.connect(path)) as conn:
conn.execute(f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY)")
with self.assertRaises(sqlite3.DatabaseError):
migrate_saved_views(conn)
# The savepoint rolled back, so the foreign table is untouched.
self.assertEqual(
conn.execute(
f"SELECT COUNT(*) FROM pragma_table_info('{TABLE}')"
).fetchone()[0],
1,
)
self.assertTrue(Path(path).exists())
if __name__ == "__main__":
unittest.main()
+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()
+150
View File
@@ -0,0 +1,150 @@
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, []);
});
// #249 PR F-b Task 3: every page is rebuilt, so no rule waits for a page PR any more and the history page's
// production-only rules live in the overlay in the concept's tokens (D48). A comment that opens with a page PR
// ("PR F: ...", "PR E/F: ...") or defers to one ("until ... PR", "retired in PR F") is a waiting rule; a provenance
// label inside a heading ("(#249 PR E)") is not.
const WAITING_RULE = [
/\/\*\s*PR [A-F](\/[A-F]|-[ab])?\b/i,
/\buntil\b[^*]*\b(PR [A-F]?|rebuild)/i,
/\bretired in PR\b/i,
/\binterim page guards\b/i,
];
const waitingRules = css => WAITING_RULE.filter(pattern => pattern.test(css)).map(String);
test('the waiting-rule guard catches the retired comments and spares provenance labels', () => {
// The exact comments this PR retired (workspace.css and library.css at 10a4847).
for (const retired of [
'/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text',
' that keep their own design until their page PR rebuilds them in concept markup. */',
'.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */',
'.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */',
' .ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */',
'/* Until their pages rebuild (PR F history): the history page\'s and the recorded run\'s prompt blocks keep their',
'/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */',
" keeps the concept's) and are retired in PR F (history). */",
]) assert.notDeepEqual(waitingRules(retired), [], retired);
for (const kept of [
"/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */",
'/* History variants panel (#249 PR F). */',
]) assert.deepEqual(waitingRules(kept), [], kept);
});
test('the overlay keeps no rule waiting for a page rebuild', () => {
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
assert.deepEqual(waitingRules(css), []);
for (const retired of ['.library-chip', '--library-positive', '--library-negative', '--prompt-role']) {
assert.equal(css.includes(retired), false, `${retired} is retired`);
}
});
// #249 C8 and D3: the Images page and the inspector render the concept DOM, so the replaced production rules stay
// retired; the few production-only rules still consumed live in the overlay.
test('the overlay keeps no retired Images page or inspector rules', () => {
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
// `.image-grid` and `.image-card-actions` are concept selectors now, so only the retired production names are listed.
for (const retired of ['.image-facts', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation',
'.image-details', '.file-cover', '.file-inspector-inner', '.file-title', '.file-chip', '.file-facts', '.generation-facts', '.file-inspector-footer']) {
assert.equal(css.includes(retired), false, `${retired} is retired`);
}
});
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), then the production overlay last; the page
// stylesheets that waited for their PRs are all retired (PR F-b deleted the last, library.css).
test('workspace.html loads controls, viewer, concept 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/workspace.css',
]);
});
test('the retired shell, vocabulary, editor, Images, inspector and library stylesheets stay deleted', () => {
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css', 'library.css']) {
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
}
});

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