Compare commits

...
Author SHA1 Message Date
Vito Sansevero 6ae6b60ac5 Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2 2026-10-28 22:14:05 -07:00
Vito 505c16a724 test(collections): order the review M1 reads by events, not the 300 ms timer (#459) (#463)
The M1 test let the rename answer at once and changed the hash only after the
toast, so on a loaded host the rename's 300 ms count read could start before
Fresh's page read; the page then waited on that one read and the poll for two
held reads failed. Hold the rename's answer until Fresh's read is held.
2026-10-29 04:36:30 +00:00
Vito 66e567cc95 fix(workspace): remove the 1-5 prompt rating from the workspace (#447) (#461)
The star is only for favorites (maintainer ruling 2026-10-28). The workspace
no longer shows or edits a prompt rating: no Rating field in the Library
editor, no stars on Library cards, no "Highest rated" sort on Images or the
Library, and no rating column or rated-images total in System > Statistics.

The editor never sends `rating`, so a workspace save keeps a stored rating
as it is. A saved view or Library visit with the retired rating sort opens on
Recently added. Data and API are unchanged; legacy pages keep their ratings.

Recorded as deviation D153 (amends D14, D23, D96); concept comparisons map
the concept through it.
2026-10-29 04:04:52 +00:00
Vito Sansevero 42bc73433c test(monitor): a switched-off node in the recorded saver's branch is never foreign (#247, #90)
PR 3's repair skips a file whose recorded saver's branch reads a
switched-off PromptManager node. On the monitor the same shape never reaches
the foreign path: a literal switch is the opted-out path's and a wired one
the node's own run's. Nothing is stored at settle or after the run, even
from a recorded run holding the off text.
2026-10-28 20:21:25 -07:00
Vito Sansevero 28e2bc0941 docs(system): renumber the foreign-runs switch deviation to D158 (#247)
#430 took D147 on main; the Save prompts from other workflows row is D158.
2026-10-28 20:16:00 -07:00
Vito Sansevero 7714c42558 Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2 2026-10-28 20:15:25 -07:00
Vito Sansevero dc1258d0bd fix(monitor): an undecided not_saved waits for its run's record (#247, #90)
A not_saved from the ladder's undecided step is not final before a record
exists: the recorded saver may still resolve another branch. The settle
lookup now keeps such a file waiting; with the record it stores nothing and
logs the opted-out line. An early not_saved (a switched-off node upstream of
any sampler's positive) never reaches the lookup and stays final.

Tests: the undecided case (literal and wired switch) waits, then skips; a
switched-off node feeding one branch of a shared-prefix run never waits and
stores nothing, not even the branch the recorded saver names.
2026-10-28 20:15:20 -07:00
Vito dff08bc161 fix(images): open Masonry by default, keep a saved Default layout, crop portraits from the top (#451) (#455)
- A Default layout or Thumbnail size saved in System now reaches the next Images
  visit in an open tab (this tab or another): the last visit records the
  defaults it started from, and a value its toolbar never changed follows newer
  defaults. A toolbar choice stays the current view's own.
- Images opens in Masonry when the browser has no stored Default layout
  (gallery-settings.js DEFAULT_SETTINGS.workspaceLayout); stored choices are kept.
- Grid/List card art, Library card art, the inspector cover and the Tag image
  preview crop with object-position 50% 20%, so portrait images keep their top.
- D157 in TESTING.md; concept comparisons start from the concept's stored Grid.
2026-10-29 02:55:55 +00:00
Vito aad3a37bcf fix(inspector): give image tags their own labelled section (#449) (#454)
The inspector drew a file's tags as a bare chip row right under the negative
prompt, so they read as part of it. The side panel, the viewer details and
History's current file now draw them in labelled sections in the prompt
sections' frame: "Image tags · N" for the file's own tags, then "Prompt tags
· N" or "Recipe tags · N" for the linked tags. Each count is true: repeats are
removed within a source only. Recorded as D154.
2026-10-29 02:44:42 +00:00
Vito d03d0ed1c5 fix(history): count cached re-runs of runtime-linked text as tracked runs (#403) (#445)
* fix(history): count cached re-runs of runtime-linked text as tracked runs (#403)

Since #347 a cached re-run of runtime-linked prompt text (PreviewAny or an
LLM into a PromptManager text input) counts in the Library, but its history
capture had no raw text, so the history page's "tracked runs" stayed at the
number of jobs in which the node executed.

The #347 receipt now also keeps the raw, prepend and append text the node
gave the history capture. When a cached node's graph inference leaves a field
unknown, the capture takes it from the receipt that matches the cache entry,
labeled inferred/graph_semantics, so the job joins the prompt's history scope
and gets its prompt hint. No schema change and no new evidence vocabulary;
no backfill. Fails closed on no receipt, a disagreeing known field, equal
outputs with different fields, oversized or invalid text and a full budget
(least recently used receipts give up their fields first; counting stays).

* docs(testing): link the whitespace history gap to #438
2026-10-29 01:40:02 +00:00
Vito Sansevero 379f0ad1ac fix(monitor): a foreign not_saved decision logs its skip and never waits (#247, #90)
The foreign path stored nothing for a ladder not_saved decision, but logged
it as "No certain prompt" and, when only the legacy answer was a
switched-off node's text, kept the file waiting for a seal that could only
store nothing. Such a decision now ends the wait at settle, and both settle
and the re-check log the opted-out path's own line (a wired switch says so).

Tests cover an only-PromptManager node switched off (opted out, not
foreign), a switched-off PromptManagerText behind a pass-through into a
core CLIPTextEncode, a wired switch, a negative-only switched-off node,
recorded history holding the off text, and foreign saving off.
2026-10-28 15:52:26 -07:00
Vito Sansevero 432dd219ab Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2
# Conflicts:
#	TESTING.md
2026-10-28 15:34:09 -07:00
Vito Sansevero f318ff2f9b Merge commit '264d9c3' into feat/247-foreign-workflows-pr2
# Conflicts:
#	tests/comfyui/image-attribution.spec.mjs
#	tests/vendor/comfyui-config.test.mjs
2026-10-28 15:33:56 -07:00
Vito Sansevero 264d9c3791 fix(ingestion): the switch walk also skips any input named with "negative"
#90's rule matches only whole underscore-separated words, so negative2,
negativeprompt and NegativePrompt counted as positive inputs and an off
node wired only to one of them blocked the file. The walk skips those too;
follows_input is unchanged.
2026-10-28 13:12:47 -07:00
Vito Sansevero dc0df5760f fix(ingestion): the switch walk skips negative inputs by #90's own rule
The upstream walk treated only names containing "negative" as negative, so
an off node on a neg, neg_text or text_neg input blocked an enabled
positive. prompt_graph gains is_negative_input (the _NEGATIVE_KEY rule, also
used by follows_input) and the walk uses it. The guards accept a node with
null inputs and a side whose provenance is not an object.
2026-10-28 13:07:40 -07:00
Vito Sansevero 982464b7f2 fix(ingestion): any switched-off node upstream of a positive stores nothing (#90)
The ladder walks every input upstream of each sampler or guider positive
(not negative, model, clip, latent or image inputs) for PromptManager nodes
whose Save to library is off or wired from an unreadable node, so the
sampler-less step, an off node with a prepend part and a PromptManagerPair
behind a pass-through no longer store its text. The legacy-floor guard reads
a node's resolved text. Every guard reports not_saved for a switch that is
off and not_saved_wired for one it cannot read.
2026-10-28 12:57:37 -07:00
Vito Sansevero cf371094b7 fix(ingestion): a switched-off PromptManager node behind a pass-through stores nothing (#90)
The #90 graph walk follows only prompt-like inputs, so a PromptManagerText
with Save to library off reaching the encoder through another input name
(easy showAnything.anything, a custom node's value) was invisible to it,
and the inspector's resolve stored its text. The ladder now checks the
positive every step names: a resolved or inspected positive whose path
passes a node that is off, or a legacy answer that is such a node's own
text, makes the file not_saved.
2026-10-28 12:46:57 -07:00
Vito Sansevero 02ce5e79bc Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows
# Conflicts:
#	py/api/admin.py
2026-10-28 12:03:04 -07:00
Vito Sansevero 81e1c685f0 Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows
# Conflicts:
#	database/run_attribution.py
#	tests/test_recorded_saver_anchor.py
#	tests/test_run_attribution.py
#	utils/prompt_inspector.py
2026-10-28 08:39:39 -07:00
Vito Sansevero 6d99447d76 Merge branch 'feat/247-foreign-workflows' into feat/247-foreign-workflows-pr2 2026-10-28 07:36:10 -07:00
Vito Sansevero d1cb0ea437 test(comfyui): each branch of a shared-prefix foreign run links to its own prompt (#247 CUJ 1) 2026-10-28 07:32:35 -07:00
Vito Sansevero be49944a63 fix(system): the foreign row can be set while the monitor still watches; its note describes it (#247, D147)
Review fixes: while monitoring is saved off but a source still reads
watching until restart (D128), the row stays enabled with its note; the
note is the switch's aria-describedby. The browser spec restores
save_foreign_runs on the shared server. TESTING.md moves D147 into order
and records the monitor's review rules.
2026-10-28 07:31:01 -07:00
Vito Sansevero 0d0ffc4ad8 fix(monitor): wait for the run unless history answered; monitoring off stops foreign writes (#247)
Review fixes: at settle a certain answer ends the wait only when the
lookup found a record or the answer is the inspector's or the recorded
saver's; the legacy floor waits for the re-check. A record without a
certain prompt ends the wait at once. Foreign writes need both
save_foreign_runs and monitoring_enabled, read again just before the
store. Dropped foreign files are logged outside the lock (evictions as
evictions, due ones too), and a drained re-check never drops a newer
reference.
2026-10-28 07:30:03 -07:00
Vito Sansevero 3dfb828ae5 fix(ingestion): match a file's link rows under its resolved spelling too
The scan stores realpath spellings and the monitor the watcher's raw path,
so a file reached through a symlinked or junction output folder could get a
second generated_images row. file_link_rows now queries the path and its
realpath (each normalized and raw) and dedupes by row id. TESTING.md notes
the reverse order is matched only when the raw spelling is the realpath.
2026-10-28 07:28:48 -07:00
Vito Sansevero b1383adcc3 fix(monitor): read a foreign file's size only when the ladder needs it (#247)
Uses PR 1's lazy file size and pins that a foreign file linked to another
prompt keeps its one link on the pair and the positive store paths.
2026-10-28 07:06:00 -07:00
Vito Sansevero 6536cee5cd Merge branch 'feat/247-foreign-workflows' into feat/247-foreign-workflows-pr2 2026-10-28 07:05:19 -07:00
Vito Sansevero 45b9d36aa2 test(vendor): the execution matrix counts the #247 real-host case 2026-10-28 07:01:17 -07:00
Vito Sansevero 982c1efff6 test(comfyui): a workflow of ComfyUI's own nodes creates and links its prompt (#247)
The real-host attribution spec gains the foreign case (CLIPTextEncode,
KSampler, SaveImage): its prompt is created and linked at settle and a
second run links to the same prompt. The fixture route lists the waiting
foreign files. TESTING.md records the monitor's foreign path and D147.
2026-10-28 07:00:14 -07:00
Vito Sansevero b6d614f1ca feat(system): Save prompts from other workflows in Sources & scans (#247, D147)
A host setting row below Watch for new outputs drafts save_foreign_runs
and saves with the one Save row; the server applies it at once, so no
restart is named. While monitoring is drafted off the row is disabled and
says it applies while Watch for new outputs is on. The concept comparison
maps the inserted row and its mutation check covers it.
2026-10-28 06:51:05 -07:00
Vito Sansevero 57a7f13b4d feat(settings): save_foreign_runs through POST /settings, applied live (#247)
GET /settings reports settings.save_foreign_runs; a save takes a strict
bool, writes gallery.monitoring.save_foreign_runs and publishes it to
GalleryConfig in the commit, with no restart field. The diagnostics
report lists it. The legacy contract snapshot gains the added key (an
addition, no legacy key moves).
2026-10-28 06:44:33 -07:00
Vito Sansevero b107de70f8 fix(scan): one prompt per file for recorded-run links; writes survive a library move
The recorded run's stored prompt now links in one transaction with the file's
link rows, so a file linked to another prompt is a conflict instead of a
second link. The scan's stores run through survive_library_switch. A single
file asks history once (a one-file _BatchHistory), the scan passes
in_output=False and reads the file size only for files with prompt
metadata. A conflict row reads "Kept: already linked to a different
prompt". TESTING.md records the rules.
2026-10-28 06:43:48 -07:00
Vito Sansevero b313224d46 feat(monitor): store the certain prompt of a foreign workflow's output (#247)
A foreign output (no positive PromptManager node) is decided by the shared
ladder with the MONITOR policy. Certain files are stored at settle with
RUN_MARKS through survive_switch; files only recorded history can decide
wait in their own bounded set and are re-checked once after the run's
outputs are sealed, with the sealed reference. The setting
GalleryConfig.SAVE_FOREIGN_RUNS is read for every file.
2026-10-28 06:43:35 -07:00
Vito Sansevero a6d634509b fix(ingestion): keep the legacy answer that is a PromptManager node's own text
The joined-text refusal also refused the #170 parity files whose
PromptManager prepend is a link the browser parser does not follow: its
answer is then the node's literal text, which is what the node stores.
Refuse only an answer that is not such a node's own text.
2026-10-28 06:43:02 -07:00
Vito Sansevero e137270f45 fix(ingestion): no joined PromptManager text from the legacy floor; junk keeps its step's status
The legacy parser returns prepend + text + append, so its answer is refused
when a PromptManager-family node has a prepend or append part (#343). Junk
found at the undecided-graph step keeps that step's unlinked status and
review, so an ambiguous file is still queued for Needs review. The monitor
policy's ambiguous file no longer asks for the recorded run, and the file
size may be read lazily.
2026-10-28 06:41:55 -07:00
Vito Sansevero 6a494c7c1f feat(config): gallery.monitoring.save_foreign_runs, default on (#247) 2026-10-28 06:41:45 -07:00
Vito Sansevero 4b4fa7f8ff fix(ingestion): keep the file-name saver anchor out of the ladder (#390)
The #390 ruling queues a file that only its saver's file name resolves for
Needs review, never linking it. inspection_resolution gains name_anchor;
the ladder passes False, so such a file falls to the legacy floor in the
scan (and to Needs review) and is never stored by the monitor, while the
recorded saver still decides. The inspector keeps the name anchor.
2026-10-28 06:29:23 -07:00
Vito Sansevero 4a90a5dd83 feat(scan): decide each scanned file with the shared ladder (#247)
_ingest_scanned_file calls decide_file_prompt (scan policy) and stores the
result: pairs through ingest_resolved_pair and positives through
store_scanned_prompt, both with the scan marks. Files the default profile
leaves undecided now get the inspector's resolve, with the saver recorded
history names, before the legacy parser, so each branch of a shared-prefix
dataset run links its own prompt. Junk text is never stored, a file linked
to another prompt is a conflict instead of a second link, and a failed link
leaves no prompt behind. _BatchHistory reads the stored prompt and the
recorded saver from one recorded_runs pass. _parse_comfyui_prompt calls the
moved parse_comfyui_prompt. TESTING.md documents the decision.
2026-10-28 06:20:53 -07:00
Vito Sansevero e3191853c8 feat(ingestion): store a file's prompt in one transaction, one prompt per file
database/scanned_prompts.py links a file to the prompt of one text, creating
it when new, inside one BEGIN IMMEDIATE transaction: a file linked to
another prompt is a conflict, a failed link leaves no prompt behind and two
writers store one prompt. pair_ingestion gains file_link_rows,
link_conflict and Marks/mark_created; ingest_resolved_pair takes optional
marks for the components it creates. recorded_runs and named_prompt in
run_attribution become public (#247).
2026-10-28 06:11:20 -07:00
Vito Sansevero 9bf8362d22 feat(ingestion): one per-file prompt decision for the scan and the monitor
utils/file_prompt.py holds decide_file_prompt, the ladder the output scan
runs today plus the inspector's rule (inspection profile, then the recorded
saver) before the legacy parser, with a SCAN and a MONITOR policy. Junk
text (blank, numbers, LoRA tags only, model file names) is never stored.
graph_prompt_text and parse_comfyui_prompt move here; the parser accepts
parsed metadata (#247).
2026-10-28 06:09:38 -07:00
Vito Sansevero a90d251f39 refactor(inspector): share the inspector's resolve as inspection_resolution
inspect_prompt_evidence now calls inspection_resolution, which returns the
resolution to use, the anchored result and whether the recorded saver chose
it. Ingestion can call the same helper for files the default profile cannot
decide (#247).
2026-10-28 06:06:52 -07:00
Vito Sansevero a8e56f1812 Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows 2026-10-28 06:00:32 -07:00
Vito Sansevero 3bba78d0eb Merge remote-tracking branch 'origin/main' into feat/386-recorded-attribution 2026-10-28 03:26:47 -07:00
Vito Sansevero 2d7e138624 fix(history): look up recorded runs by history's own spelling of the file (#386)
The history route inspects a case-normalized target on Windows, while
history matches file names exactly, so the occurrence's reference names
the file.
2026-10-28 03:26:44 -07:00
Vito Sansevero e34c1e2351 fix(inspector): recorded texts follow history's saver; conflicts anchor nothing (#386)
Review follow-ups: the texts PromptManager recorded are looked up at the
sampler history's saver leads to, never at a sampler the unanchored walk
named; a file name naming a different saver than history anchors nothing;
outputs and texts are read in one transaction; "Recorded run" ranks below
every workflow-inferred source; unexpected lookup errors log at error level.
2026-10-27 16:21:00 -07:00
Vito Sansevero 68443551b2 feat(inspector): use recorded run history when the graph alone can't name the prompt (#386)
When a file's own graph leaves the prompt open, both inspect routes ask
recorded run history about the file (one run reported it and it was written
during that run). The save node that run reported it from anchors the walk
like a file-name match, so dataset branches that share one saver prefix
show their own prompt, labelled "Recorded run". Still open at one sampler
whose PromptManager text was recorded at execution (a language model's
output), the recorded text shows as "Execution recorded". Results the file
resolves alone never change; a failed lookup judges the file alone.
2026-10-27 16:14:36 -07:00
74 changed files with 3339 additions and 404 deletions
+225 -22
View File
@@ -731,8 +731,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
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 naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image
tags first, D88, then the linked tags; image tags and the linked positive's own tags
chip naming its origin (D146), the + and − prompt blocks with copy and send, the tag chips in labelled
sections after the negative prompt (D154: "Image tags · N" for the file's own image tags, D88, then "Prompt
tags · N" or "Recipe tags · N" for the linked tags; image tags and 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
@@ -1385,8 +1386,8 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
- **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").
Library's categories, `#sort` (Recently added, Name A–Z, then Oldest first and Name
Z–A; no Highest rated, D153), 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).
@@ -1396,7 +1397,7 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
(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.
scroll (a layout or size the toolbar never changed follows newer defaults, D157).
- **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
@@ -1412,7 +1413,7 @@ How the earlier workspace Images filters moved (old → new). The legacy `/galle
| 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) |
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A (D13, D14; no Highest rated, D153) |
| (none) | toolbar category (the effective category, D141) 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 |
@@ -1523,6 +1524,9 @@ Recorded deviations on this page (spec §8):
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
and are filters too; a linked tag already shown (case-insensitive) is left out.
Amended by D154 (#449): the image tags and the linked tags each sit in their own
labelled section, and a linked tag stays in its section even when an image tag
has the same name.
Amended by D146 (#332): the category chip also names its origin in visible text
after the category (" · this image", " · prompt", " · recipe"), and an image's own
category shows for an unlinked or ambiguous file too.
@@ -1682,6 +1686,29 @@ Recorded deviations on this page (spec §8):
`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.
- **D157** (#451, maintainer request, beyond the concept): Images opens in **Masonry**
when this browser has no Default layout (`gallery-settings.js`
`DEFAULT_SETTINGS.workspaceLayout`); the concept starts at Grid (`state.view`, app.js
208). A stored choice is kept, and an invalid value reads as Masonry. Saved views,
favorites, collections and the embedded galleries keep their fixed Grid/Comfortable.
The return to Images (the last visit) keeps a layout or thumbnail size its toolbar
changed; a value it kept from the defaults it started with follows a Default layout or
Thumbnail size saved since, in this tab or another (a toolbar List then Default =
Masonry stays List; a toolbar List then back to Grid counts as untouched). A cover crop
of a generated image (Grid and List card art, Library card art, the side panel's and
History's inspector cover, the Tag image preview) uses `object-position: 50% 20%`
instead of the concept's centre: it acts only when the image is taller than its box
(portraits keep heads and faces), and a wider image stays centred. Masonry shows the
whole image and keeps the centre. The concept comparisons (`openProduction`) and the
specs of the Grid's own geometry start production from the concept's state, a stored
Default layout of Grid (`startInGrid`, concept-inventory.mjs, only while the browser
stored nothing); the comparisons do not measure `object-position`.
`a fresh browser opens Images in Masonry (#451)`, `a Default layout saved later reaches
the next Images visit in this tab (#451)`, `... in another tab ... (#451)` and `cover
crops keep the top of a portrait image; Masonry stays centred (#451)`
(workspace-system.spec.mjs) cover it. A browser whose legacy gallery wrote the old
default (`"grid"`) keeps Grid until Default layout is changed or "Reset display
defaults" is saved.
`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:
@@ -1787,6 +1814,30 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
concept image's tags saved as its image tags production still equals the concept.
After a Tag save the selected file's inspection is re-read, so the panel redraws
the row without reselecting and the viewer, which shares that inspection, shows it.
Amended by D154 (#449): the one row is now two labelled sections.
- **D154** (#449, beyond the concept): the inspector's tags no longer follow the
negative prompt as a bare `.tags` row, where they read as part of it. Every surface of
the shared inspector (the Images side panel, the viewer details, History's inline
current file, the Library-embedded gallery and Activity) draws them in
`section.tag-section` regions in the concept's prompt-section frame (the
`.prompt-section` top rule with 15px padding and margin, a `.prompt-heading` with a
`.role-label`, the row's own 12px top margin dropped; workspace.css). The label is
muted (`--ca-muted`, as `.meta-grid dt`), because tags are neither prompt role. First
`section.tag-section.image-tags` "Image tags · N" (the file's current image tags, D88),
then `section.tag-section.linked-tags` "Prompt tags · N" for the linked positive's
tags or "Recipe tags · N" for a recipe's own (`tags_scope`, named as D146's category
chip names its origin). Each region is named without its count and with the prompt
blocks' context ("Current file image tags" in History). An empty source draws no
section. Repeats are removed within a source only, so a linked tag that is also an
image tag shows in both sections and each count is true. D21's filter rules are
unchanged. History's current-file read carries no `image_tags` facet (D88), so there
only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the
concept's row in the same sections at runtime (`withTagSections`, the D154 rule
injected) and compares the measured sections (frame, heading, label, row); a
mutation of the label color, the row margin or the top rule fails it. Browser tests:
"D154: image tags sit in their own labelled section after the negative prompt, in
the panel and the viewer" and "the inline current file labels its image tags after
its negative prompt".
- **D115** (beyond the plan's list): the concept always has a selected image, so
its "Auto-tag images" always opens the dialog. Production opens it for the image
selected in Images when it was last left; with none it is `aria-disabled` and
@@ -1872,6 +1923,7 @@ production-versus-concept difference of PR C and its record:
| Suggestions count field | D91 (ruling T3-bulk) |
| No prototype note, "Suggestions from …", no " in this tab" | D29 |
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
| Image tags and linked tags in labelled sections after the negative prompt | D154 (#449) |
| Auto-tag images `aria-disabled` without a selected image | D115 |
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
| States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
@@ -2352,7 +2404,7 @@ 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
`.card-footer` (the 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.
@@ -2362,8 +2414,8 @@ Recorded deviations on this page:
"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.
browser reads as today), and "Not used yet" without one. There are no rating stars
(D153, amending D23): every 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
@@ -2524,6 +2576,48 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no
Add tags); units `tests/js/workspace-library-selection.test.js`; Python
`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart.
### No 1–5 prompt rating in the workspace (#447)
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
a 1–5 rating): the workspace shows and edits no prompt rating. The concept's rating
appears in four places, and production draws none of them:
- Library cards: the concept's footer reads "★★★★★ · Used today"; production reads only
the last use (amends D23). `library-concept.spec.mjs` takes the stars and their " · "
out of the concept page itself before measuring (`dropConceptRatings`), so the footer
facts are compared at production's width; it first checks that every concept card had
them.
- Sort: the concept's "Highest rated" is not offered on Images or the Library (amends
D14); the comparisons drop it from the concept's options. A saved view or a Library
visit that still names `sort: "rating"` opens on Recently added; the server keeps such a
view as it was stored.
- Editor (New prompt, Edit recipe metadata, Create text revision): no `#recipe-rating`
"Rating" select; `library-editor-canonical-comparison.mjs` `mapConcept` drops the
concept's Rating field. The editor never sends `rating`. An edit sends only changed
fields, so a stored rating stays as it is; a new prompt and a text revision create an
unrated positive (the revised prompt keeps its own rating; the legacy admin page shows
the revision as unrated). A reuse that leaves metadata unapplied compares title,
category, tags and notes only. The edit token still covers `rating`, so a rating
changed on a legacy page while the editor is open is an ordinary metadata conflict
whose review shows no visible difference.
- System > Statistics: no "Avg. rating" column and no "Rated images" total (amends D96);
`system-concept.spec.mjs` maps the concept's table without that column.
Data and API are unchanged: the `rating` column (`CHECK` 1–5), `PUT
/prompt_manager/prompts/{id}/rating`, `rating` in Library, Images and statistics
answers, and the server's `rating` sorts. The legacy pages keep their ratings until the
cutover (#334). Favorites (the image star) are unchanged.
Tests: units `tests/js/workspace-library-editor-state.test.js` (a prompt rated 4 edited
and saved keeps 4, no `rating` in any body, no rating field),
`workspace-library-state.test.js`, `workspace-images-state.test.js`,
`workspace-saved-views.test.js`, `workspace-system-overview.test.js`,
`workspace-library-format.test.js`; browser `workspace-library-editor.spec.mjs` (the
fixture's positive stored with rating 4 still reads 4 after Edit, a revision, Add
negative and a reuse), `workspace-library.spec.mjs`, `workspace-images.spec.mjs`,
`workspace-saved-views.spec.mjs`, `workspace-system.spec.mjs` and the concept
comparisons above.
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
@@ -3144,7 +3238,8 @@ until this browser dismissed the 3.2.0 update notice (`pm_update_notice_dismisse
the legacy admin page's key); Later dismisses it without scanning, and Review scan
dismisses it as the legacy Scan does. "+ Add source" asks validate-source, then adds
the folder to the host draft of `gallery_root_paths`; Remove takes one out; "Watch for
new outputs" (`monitoring_enabled`) and Worker threads (`worker_threads`) are host
new outputs" (`monitoring_enabled`), "Save prompts from other workflows"
(`save_foreign_runs`, D158) and Worker threads (`worker_threads`) are host
drafts too, and Save writes them with the revision. A save the server refuses because a
scan runs toasts "Another job is running: <title>." with a link to that job (D57) and
keeps the drafts; a save refused for one source says the server's message as the status
@@ -3393,7 +3488,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
reader can keep it byte-identical (`test_health_check_never_writes`).
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 assigned). D94,
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 and D158 assigned). D94,
the per-section hand-off panel, was retired by S12 (#329): every section is native.
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
@@ -3439,12 +3534,13 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
every check passed, some could not run, or issues need review. Close / Review missing
links, which reviews a fresh Missing-file links plan (D100, S7); it never cleans up.
Nothing is changed by a check.
- **D96** (Statistics): "Usage statistics" without the sample note: the concept's
"Runs by category" table (Category / Runs / Avg. rating) with the real runs and
average rating of eligible parents per category ("Uncategorized", "Not rated"),
then a second table "Library totals" (Total / Count: Prompts, Tags, Images from the
legacy statistics, and Rated images: indexed images whose linked prompt is rated).
The Overview row says "Runs and ratings by category, with library totals." because
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
with the real runs of eligible parents per category ("Uncategorized"), then a second
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
statistics; no Rated images). The API still reports `average_rating` and
`rated_images`; the workspace does not show them.
The Overview row says "Runs by category, with library totals." because
that is what the dialog shows (the concept's own dialog shows only "Runs by
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
table of them exists yet. A failed read toasts "Statistics could not
@@ -3881,6 +3977,26 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
the batch tests of workspace-system-tagging.spec; the mutation check renames each grid
label in turn.
- **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows
without a PromptManager node; the maintainer chose a visible switch over a config key.
A `.setting-row` in the concept's toggle markup follows "Watch for new outputs":
"Save prompts from other workflows" (`#foreign-runs`, host key `save_foreign_runs`,
config.json `gallery.monitoring.save_foreign_runs`, default on), "New outputs of
workflows without a PromptManager node add their prompt to the library when it is
certain.". It is saved with the revision like the other host rows and applied at once
(the monitor reads it for every file), so a save names no restart. While "Watch for
new outputs" is drafted off, a `p.small` under the description says "Applies while
Watch for new outputs is on." (the switch's `aria-describedby`; the value is kept) and
the switch is disabled, except while a source still reads "watching until restart"
(D128): the running observer still delivers files then, so the value can be set. The
server stores no foreign prompt while `monitoring_enabled` is saved off, whatever the
observer does.
Pinned by `sources matches the concept` (the mapping inserts the row after the
monitoring row), `sources comparison detects mutations` (a missing row and a changed
label are caught), `D158: Save prompts from other workflows saves save_foreign_runs
live; off while monitoring is off (#247)` (keyboard on desktop, a tap on touch) and
`D158: saved off while the monitor still watches until a restart, the foreign row can
still be set (D128)` (workspace-system-sources.spec) and the system-sources node test.
- **D109** (Logs, S12, #329): the concept's Logs surface on the live server. The toolbar
keeps "Level", Refresh and the download (renamed "Download log": the active log,
`/logs/download/prompt_manager.log`, through the api client), with all six minimum
@@ -5808,6 +5924,70 @@ Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class),
saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and
`tests/test_recorded_saver_anchor.py` `TestInspectionResolution`.
**In the monitor.** An output whose workflow has no positive PromptManager node
(`_find_prompt_from_metadata` says `foreign`) used to be logged and skipped. It now
goes through the same ladder with the `MONITOR` policy (`utils/foreign_runs.py`, the
handler's `_ingest_foreign`, `_recheck_foreign`, `_store_foreign`):
- **At settle.** The ladder runs without history. Certain (`pair` or `positive`): the
prompt is stored and the file linked at once. `history` (an undecided graph inside
ComfyUI's output folder): the file is registered in its own waiting set, then the
ladder runs again at once with the file's recorded output, because the run may have
been sealed before the file settled. When that lookup finds a record, its answer is
final: certain is stored, anything else is logged "No certain prompt for <file>;
left unlinked" and the file stops waiting (no seal is coming). When it finds none,
only the inspector's or the recorded saver's answer is stored; anything else (the
legacy floor included) keeps waiting for the re-check. Anything else at the first
pass is logged "No certain prompt for <file>; left unlinked".
- **After the run ends.** When the run's outputs are sealed, each waiting foreign file
gets one re-check (`ForeignRecheck(reference)`) with the sealed output reference,
so history is asked with the spelling it recorded (a Windows folder whose case
differs from the disk still links). Not certain: "No certain prompt for <file>
after its run ended; left unlinked". An ambiguous inspector answer is never given
the legacy floor in the monitor (D-Q3); an untraceable one still is.
- **Waiting sets.** `_awaiting_foreign` and `_foreign_due` sit beside the keyless sets
with the same bounds (1000 files, one hour) but are separate, so a large foreign run
never evicts a waiting keyless file. An expired entry is logged "No recorded run
for <file>; left unlinked", an evicted one (waiting or due) "Too many files wait for
their run; <file> left unlinked", both after the lock is released. A due re-check
is dropped only when it was scheduled, never a newer reference for the same file. `awaiting_foreign()` lists them;
`shutdown()` and `close()` clear them, and a closed handler re-checks nothing.
- **Store.** One write through `survive_switch` (rerun after a Move library): a pair
through `ingest_resolved_pair(..., marks=RUN_MARKS)`, a positive through
`store_scanned_prompt_or_raise(..., RUN_MARKS)`. A created prompt carries category
`scanned`, tag `auto-scanned` and the note `Saved from a workflow run: <file>`; a
file linked to another prompt is `conflict` and keeps its link. Logged at info
"Foreign run: <status> prompt <id> for <file> (<source>)"; a failure is logged and
the file stays unlinked.
- **Never.** No tracker FIFO entry is read or consumed, nothing is counted (run count
stays 0, no `last_used_at`), and nothing is queued for Needs review.
- **The setting.** `GalleryConfig.SAVE_FOREIGN_RUNS` (`gallery.monitoring.
save_foreign_runs`, default true, only a bool applies) is read for every file, at
settle, at the re-check and again just before the store, together with
`MONITORING_ENABLED`: either saved off stops foreign writes at once (the running
observer watches until a restart, D128); off logs "No PromptManager prompt in
workflow, skipping image" as before. System › Sources & scans "Save prompts from other workflows" (D158)
saves it through `POST /prompt_manager/settings` with no restart field; the
diagnostics report lists it.
Tests: `tests/test_run_attribution.py` `TestMonitorForeignRuns` (dataset branches
linked after the seal, a seal before settle, a name two runs reported, the setting
off at settle and at the re-check, a plain workflow linked at settle with no run
count and the tracker queue untouched, the sealed reference's spelling, the separate
bound, expiry, a seal while the pending set is full, shutdown, a failed store, no
record at settle keeps waiting, a record without an answer ends the wait, monitoring
saved off, the setting read before the store, a seal between registering and the
settle lookup, a newer due reference kept, evictions logged, one link per file),
`tests/test_image_linking.py` `TestForeignWorkflows`, `tests/test_image_monitor_singleton.py`
(close drops a foreign re-check), `tests/test_config.py`,
`tests/test_settings_sources.py` `TestSaveForeignRunsSetting`, and the real-host
`tests/comfyui/image-attribution.spec.mjs` case "an output of a workflow without a
PromptManager node creates and links its prompt; a second run reuses it" (ComfyUI's
own `CLIPTextEncode`, `KSampler` and `SaveImage`), and "each branch of a
shared-prefix workflow without a PromptManager node links to its own prompt after the
run" (two branches, one save prefix: the recorded saver names each file's branch).
The fixture route's GET also lists `foreign`, the waiting foreign files.
## Linked prompt text (#343)
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
@@ -5905,8 +6085,26 @@ decide through the same `own_counts`.
- A cached node counts at job start like the hook: a job failing later still counts it.
A node that executes after its replay in the same job (RAM-pressure freed the entry)
skips its own count but still consumes the pending first-use catch-up.
- No backfill: earlier cached jobs keep their counts. The history page's "tracked runs"
can stay below the Library count for these prompts (#403).
- No backfill: earlier cached jobs keep their counts.
- History (#403): the receipt also keeps the raw, prepend and append text the node gave
the history capture. For a cached node whose graph inference leaves a field unknown,
the capture takes it from the receipt matching the cache entry
(`CachedRuns.recorded_fields`), as `inferred` / `graph_semantics`: the node did not run
in this job, the value follows from the run key (the whole upstream closure) plus the
cache proof. The job then joins the prompt's history scope, gets the
`positive_prompt_id` (and Pair) hint, and an image of it can be attributed through its
run, so the history page's "tracked runs" equals the Library count from the restart on.
Fail closed (fields stay unknown, as before): no matching receipt, a receipt that
disagrees with a field the graph knows, executions with equal outputs that recorded
different fields (a conflict that stays for that run key), a field over 64 KiB or
invalid Unicode, more than one run's history (1 MiB) in one receipt, or a full 8 MiB
field budget (least recently used receipts give up their fields first; their counting
stays). No new evidence vocabulary, so older builds still open the database.
- Still different: jobs before the restart (no backfill); list batches (per item in the
Library, once per job in history); text with leading or trailing whitespace (the
Library stores it stripped, history matches the exact raw text, #438); a job in which the
node executes after its replay (RAM-pressure freed the entry: two samples, an
ambiguous capture); jobs while the history writer is fenced (restore, library move).
- Tests: `tests/test_cached_runs.py` (whole jobs with the real nodes, a real database,
fake host caches); `tests/comfyui/execution-text-link.spec.mjs` "#347" cases on the
managed host: PreviewAny into each node kind, 3 jobs changing only the sampler seed,
@@ -5914,7 +6112,10 @@ decide through the same `own_counts`.
modes, A, B, A from the owned `PMCycledText` under LRU, typed text, a downstream
failure, a "run selected output" job that does not need the node, and a rewire to the
negative input. The RAM-pressure eviction path cannot be forced on a real host and is
covered by the Python tests.
covered by the Python tests. Since #403 these cases also check the history page's
tracked runs equal the Library count, and `tests/test_execution_history_hooks.py`
(`CachedRuntimeHistoryTests`) runs the real history hook and writer with a real
`CachedRuns`.
```sh
python3 -m unittest discover -s tests -p "test_cached_runs.py"
@@ -6013,10 +6214,12 @@ Context cases verify A,A,B as three jobs/two combinations/one positive snapshot
when every positive field matches; all five registered PM variants; exact raw
whitespace and Unicode; known empty negatives; Text negative roles and crossed
pairs; fully cached repeats after trigger configuration changes; successful source
text before downstream failure/interruption; unknown cached dynamic raw; unsupported
text before downstream failure/interruption; cached dynamic raw from the reused execution (#403); unsupported
transforms and duplicate list invocation ambiguity. Fresh values use
`runtime_return`, cached effective STRING outputs use `host_cache_output`, and
submitted literals use graph inference. SQL NULL means unknown; empty text is
submitted literals use graph inference. A cached dynamic raw is unknown unless the
#347 receipt of the execution the cache entry comes from proves it; it is then
`inferred` / `graph_semantics` (graph identity plus a same-process cache receipt, #403). SQL NULL means unknown; empty text is
known. Text is never trimmed or normalized by capture. Cache availability and
inferred sampler roles prove neither sampler execution nor consumption, and list
indices do not identify generated images. External trigger configuration changes
+6 -1
View File
@@ -275,7 +275,12 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
self._record_cached_item(unique_id, (encoding_text,), storage_text)
self._record_cached_item(
unique_id,
(encoding_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
return result
@classmethod
+13 -3
View File
@@ -243,14 +243,24 @@ class PromptManagerBase:
return False
def _record_cached_item(
self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None
self,
unique_id: Any,
outputs: tuple,
text: Any,
negative_text: Any = None,
fields: Optional[tuple] = None,
) -> None:
"""Remember this execution's outputs and stored text for cached jobs (#347)."""
"""Remember this execution's outputs and stored text for cached jobs (#347).
``fields`` are the ``(raw, prepend, append)`` texts this execution gave
``record_runtime_context`` for each output, so a cached job's history
can record them too (#403).
"""
if unique_id is None:
return
try:
self._cached_runs().record_item(
unique_id, outputs, stored_facts(text, negative_text)
unique_id, outputs, stored_facts(text, negative_text), fields
)
except Exception as error:
self.logger.debug(f"Could not record the cached-run receipt: {error}")
+5 -1
View File
@@ -198,7 +198,11 @@ class PromptManagerPair(PromptManagerFocused):
type(self).__name__, negative_text, "", "", negative_text, role="negative"
)
self._record_cached_item(
unique_id, (positive, negative_text), text, negative_text
unique_id,
(positive, negative_text),
text,
negative_text,
fields=((text, prepend_text, append_text), (negative_text, "", "")),
)
return result
+6 -1
View File
@@ -246,7 +246,12 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
self._record_cached_item(unique_id, (final_text,), storage_text)
self._record_cached_item(
unique_id,
(final_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
return result
@classmethod
+2
View File
@@ -213,6 +213,8 @@ def public_config():
"max_image_age_days": GalleryConfig.MAX_IMAGE_AGE_DAYS,
"max_concurrent_processing": GalleryConfig.MAX_CONCURRENT_PROCESSING,
"metadata_extraction_timeout": GalleryConfig.METADATA_EXTRACTION_TIMEOUT,
# config.json gallery.monitoring.save_foreign_runs (#247)
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
},
"integrations": {
"lora_manager_enabled": bool(IntegrationConfig.LORA_MANAGER_ENABLED),
+9 -2
View File
@@ -8,7 +8,8 @@ is shared with the LoRA integration's save.
GET also reports the image sources and the monitor (``sources.py``). A save
that changes the sources runs as a ``source_change`` registry hold, so it is
refused while a record-changing job (a scan) runs. ``monitoring_enabled``
takes effect at the next restart. ``POST /prompt_manager/settings/validate-source``
takes effect at the next restart; ``save_foreign_runs`` (#247) at once, since
the monitor reads it for every file. ``POST /prompt_manager/settings/validate-source``
checks one path and writes nothing.
"""
@@ -43,6 +44,7 @@ _SETTINGS_FILE_KEYS = {
"INFINITE_SCROLL": ("web_ui", "infinite_scroll"),
"WORKER_THREADS": ("performance", "worker_threads"),
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
"SAVE_FOREIGN_RUNS": ("gallery", "monitoring", "save_foreign_runs"),
"SAFETY_BACKUP": ("maintenance", "safety_backup"),
"THUMBNAIL_FORMAT": ("thumbnails", "format"),
"THUMBNAIL_QUALITY": ("thumbnails", "quality"),
@@ -58,7 +60,7 @@ _SETTINGS_FILE_KEYS = {
# The thumbnail settings a running Convert outdated job fixes (#317).
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED", "SAVE_FOREIGN_RUNS"})
# Attributes that live on AutotagConfig, named without the AUTOTAG_ prefix.
_AUTOTAG_SETTINGS = frozenset(
name for name in _SETTINGS_FILE_KEYS if name.startswith("AUTOTAG_")
@@ -264,6 +266,7 @@ class SettingsRoutesMixin:
"thumbnail_encoders": encoders,
"settings": {
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
@@ -626,6 +629,10 @@ class SettingsRoutesMixin:
if not isinstance(data["monitoring_enabled"], bool):
raise ValueError("monitoring_enabled must be true or false")
updates["MONITORING_ENABLED"] = data["monitoring_enabled"]
if "save_foreign_runs" in data:
if not isinstance(data["save_foreign_runs"], bool):
raise ValueError("save_foreign_runs must be true or false")
updates["SAVE_FOREIGN_RUNS"] = data["save_foreign_runs"]
if "safety_backup" in data:
if not isinstance(data["safety_backup"], bool):
raise ValueError("safety_backup must be true or false")
+13
View File
@@ -194,6 +194,8 @@ class GalleryConfig:
Attributes:
MONITORING_ENABLED (bool): Enable/disable automatic image monitoring
SAVE_FOREIGN_RUNS (bool): The monitor stores the certain prompt of an
output whose workflow has no PromptManager node (#247)
MONITORING_DIRECTORIES (List[str]): Directories to monitor for new images
SUPPORTED_EXTENSIONS (List[str]): Image file extensions to process
PROCESSING_DELAY (float): Delay in seconds before processing new files
@@ -210,6 +212,8 @@ class GalleryConfig:
# Image monitoring settings
MONITORING_ENABLED = True
# Read by the monitor for every file, so a save applies without a restart
SAVE_FOREIGN_RUNS = True
MONITORING_DIRECTORIES = [] # Auto-detect if empty
SUPPORTED_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
PROCESSING_DELAY = 2.0 # Seconds to wait before processing new files
@@ -251,6 +255,7 @@ class GalleryConfig:
return {
"monitoring": {
"enabled": cls.MONITORING_ENABLED,
"save_foreign_runs": cls.SAVE_FOREIGN_RUNS,
"directories": cls.MONITORING_DIRECTORIES,
"extensions": cls.SUPPORTED_EXTENSIONS,
"processing_delay": cls.PROCESSING_DELAY,
@@ -303,6 +308,14 @@ class GalleryConfig:
config_logger.warning(
"Ignoring gallery monitoring enabled: expected true or false"
)
if "save_foreign_runs" in monitoring:
if isinstance(monitoring["save_foreign_runs"], bool):
cls.SAVE_FOREIGN_RUNS = monitoring["save_foreign_runs"]
else:
config_logger.warning(
"Ignoring gallery monitoring save_foreign_runs: expected true "
"or false"
)
if "directories" in monitoring:
directories = cls._validated_directories(monitoring["directories"])
if directories is not None:
+62
View File
@@ -118,9 +118,27 @@ export async function openConcept(page,route,theme,motion='no-preference') {
await expect(page.locator('#main #page-title')).toBeVisible();
}
/**
* D157 (#451): production opens Images in Masonry when the browser has no Default layout; the concept starts at Grid.
* A comparison (and a test of the Grid's own geometry) starts production from the concept's state, a stored Default
* layout of Grid, once per tab and only while this browser stored nothing (a test's own settings win).
*/
export async function startInGrid(page) {
if (page.conceptLayoutSeeded) return;
page.conceptLayoutSeeded = true;
await page.addInitScript(() => {
try {
if (!location.pathname.startsWith('/prompt_manager/') || sessionStorage.getItem('pm-d157-seeded')) return;
sessionStorage.setItem('pm-d157-seeded', '1');
if (localStorage.getItem('gallerySettings') === null) localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' }));
} catch { /* A page without storage keeps the production default. */ }
});
}
/** 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 startInGrid(page);
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
@@ -214,6 +232,41 @@ export async function withChipSource(page,root,origin,category=null) {
};
}
/**
* #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
* has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
* runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
* holding the concept's row and each further one a copy of it. Returns the undo.
*/
export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.tag-section .role-label { color: var(--ca-muted); }
.tag-section > .tags { margin-top: 0; }`;
export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
export async function withTagSections(page,root,sections=PROMPT_TAGS) {
const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
const token=await page.evaluate(({root,sections})=>{
const container=document.querySelector(root);
const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
const anchor=row.nextSibling,parent=row.parentNode,added=[];
sections.forEach(([className,label,name],index)=>{
const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
const heading=document.createElement('div');heading.className='prompt-heading';
const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
heading.append(text);section.append(heading,index?row.cloneNode(true):row);
parent.insertBefore(section,anchor);added.push(section);
});
const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
(window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
return token;
},{root,sections});
return async()=>{
await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
await restoreRule();
};
}
/**
* 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)
@@ -662,6 +715,13 @@ export function measureInspector(selector) {
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 tagSectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
return {className:classes(section),name:section.getAttribute('aria-label'),...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)})),
row:when(row,rowOf)};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
@@ -695,6 +755,8 @@ export function measureInspector(selector) {
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
// #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
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');
+20 -12
View File
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
// 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']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-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');
@@ -139,9 +139,13 @@ test('panel matches the concept order and copy',async({page,request})=>{
// #332 MC8: the chip names where its value came from, in visible text after the category.
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
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);
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
// (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
await expect(recipeTags).toHaveClass('tag-section linked-tags');
await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.tag-section 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');
@@ -149,7 +153,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
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(2);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
await expect(panel).not.toContainText(/prompt evidence|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');
@@ -168,18 +172,20 @@ test('panel matches the concept order and copy',async({page,request})=>{
// 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.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).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();
const tagRow=panel.locator('.tag-section.linked-tags > .tags');
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']);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
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);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// 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'}}));
@@ -243,7 +249,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
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();
await panel.getByRole('region',{name:'Prompt tags',exact:true}).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();
@@ -290,14 +296,14 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
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.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > 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','Collections…']);
@@ -311,7 +317,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
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/);
// D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// 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();
@@ -467,7 +475,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name: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);
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.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > 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})=>{
+2 -1
View File
@@ -92,7 +92,8 @@ export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1
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);
// D153 (#447): production offers no "Highest rated".
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options.filter(option=>option!=='Highest rated'),'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.
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
+52 -26
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} 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.
@@ -51,7 +51,8 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
* tag sections the concept page gets at runtime from `withTagSections`); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
@@ -108,7 +109,8 @@ async function openProductionPanel(page,theme) {
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 expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
await settle(page);
}
@@ -129,13 +131,14 @@ async function withCollectionsButton(page,selector) {
}
/**
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
* without its action); returns an undo.
* The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's
* coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button
* (its "Download sample JSON", without its action); returns an undo.
*/
async function plainViewerChips(page) {
async function plainViewerChips(page,sections) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
const restoreSections=await withTagSections(page,'#viewer-details',sections);
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;
@@ -151,7 +154,7 @@ async function plainViewerChips(page) {
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await restoreSource();await restoreTargets();
await restoreSections();await restoreSource();await restoreTargets();
};
}
@@ -169,7 +172,7 @@ async function fileControlsOf(page) {
* 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) {
async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
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);
@@ -178,7 +181,7 @@ async function viewerOf(page,concept,mutation=null) {
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();}
if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
@@ -186,15 +189,18 @@ async function viewerOf(page,concept,mutation=null) {
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history.
async function inventory(page,concept=false,sections=PROMPT_TAGS) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
// labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
await restoreSections?.();
await restoreCollections?.();
await restoreSource?.();
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
@@ -258,7 +264,8 @@ async function productionSendOf(page,context) {
/** 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();
// The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
await settle(page);
@@ -323,6 +330,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
// D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
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);
@@ -352,14 +362,17 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
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 · prompt']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
const badges=panelOf(page).locator('.detail-inner > .tags');
await expect(badges).toHaveCount(1);
await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
// D146 (#332): the visible origin names where the value came from.
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
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(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
// D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(tags.locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
});
@@ -373,7 +386,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
async route=>{arrived();await held;await route.continue();},{times:1});
const count=page.locator('#result-count');
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
await reached;
await expect(count).toHaveText('Loading images…');
await expect(page.locator(CARDS)).toHaveCount(0);
@@ -383,8 +396,9 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
// and equals the concept with its row in those two sections, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
@@ -392,13 +406,14 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
const reference=await inventory(page,true);
const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
@@ -423,6 +438,17 @@ test('inspector comparison detects mutations',async({page})=>{
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
// D154: the tag section's muted label and its row's spacing are part of the mapped concept.
for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
const restore=await mutate(page,{css});
const mutated=await measure();
expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
await restore();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']});
+19 -8
View File
@@ -21,8 +21,8 @@ export function mapLibraryConcept(reference) {
// 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']};
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'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;
@@ -39,11 +39,11 @@ export function mapLibraryConcept(reference) {
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 (its own "now", where the page's clock stands) give the concept's own "4 runs"
// and "Used today". The two-line clamp is
// D153 (#447): no rating stars; the concept page itself is measured without them (`dropConceptRatings`). The
// fixture's run_count (4) and last_used_at (its own "now", where the page's clock stands) 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`)}}}));
footer:card.footer}));
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)}))},
@@ -139,9 +139,20 @@ async function sortConceptTags(page) {
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
}
/**
* D153 (#447): production draws no 1–5 rating. The concept's card footers read "★★★★★ · Used today"; the stars and
* their separator are taken out of the concept page itself, so the footer facts are measured as production draws them.
* Returns the concept's original footer texts.
*/
async function dropConceptRatings(page) {
return page.evaluate(()=>[...document.querySelectorAll('.prompt-card .card-footer > span')].map(span=>{
const original=span.textContent;span.textContent=original.replace(/^★+ · /,'');return original;}));
}
async function openConceptLibrary(page,theme,motion) {
await openConcept(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
const footers=await dropConceptRatings(page);
expect(footers,'the concept rates every card (D153 removes it)').toEqual(Array(CONCEPT_PROMPTS).fill().map((_,index)=>expect.stringMatching(/^★+ · \S/)));
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function openProductionLibrary(page,theme,motion) {
@@ -295,7 +306,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
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(card.footer.facts.text,`${motion} concept footer facts (D153: without the stars)`).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([]);
@@ -394,7 +405,7 @@ test('a run that crosses midnight still reads the concept fixture\'s day',async(
const reference=await conceptInventory(page);
await openProductionLibrary(page,'dark','no-preference');
const actual=await inventoryOf(page,'#workspace-main');
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(actual.library.cards.map(card=>card.footer.facts.text)).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
@@ -25,9 +25,10 @@ export function inventoryOf(dialog) {
demo:Boolean(dialog.querySelector('.demo-note')),
};
}
// Documented differences only: prototype Save label, prototype demo note, and data-dependent values.
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
// concept's "Rating" select is not drawn.
export function mapConcept(reference) {
return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
}
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
+3
View File
@@ -1,5 +1,6 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
@@ -252,6 +253,8 @@ test('responsive gallery defers observed size writes and still fits the new view
});}
};
});
// The Grid's observed sizes (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.media-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
+38 -13
View File
@@ -162,7 +162,6 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
@@ -178,8 +177,8 @@ function mapOverviewConcept(reference, overview) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -288,7 +287,10 @@ test('system comparison detects mutations', async ({ page, request }) => {
expect(diffInventory(expected, await measure())).toEqual([]);
});
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
/**
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
function mapStatisticsConcept(reference, statistics) {
const [runs] = reference.tables;
return {
@@ -296,10 +298,11 @@ function mapStatisticsConcept(reference, statistics) {
// No sample note (prototype copy); a second table follows the first.
body: ['table', 'table'],
tables: [
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
['Images', number(statistics.totals.total_images)]] },
],
};
}
@@ -614,10 +617,21 @@ const WATCHED = new Set(['watching', 'stopping']);
const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
label: 'Refresh' };
// D158 (#247): the row production adds directly below "Watch for new outputs", in that row's markup.
const FOREIGN_RUNS = { id: 'foreign-runs', label: 'Save prompts from other workflows',
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.' };
/** D158: the concept's "Watch for new outputs" row as the inserted "Save prompts from other workflows" row. */
function foreignRunsRow(monitoringRow, settings) {
return { ...monitoringRow, text: [['label', FOREIGN_RUNS.label, FOREIGN_RUNS.id], ['p', FOREIGN_RUNS.description, null]],
control: { ...monitoringRow.control, id: FOREIGN_RUNS.id, checked: settings.save_foreign_runs } };
}
/** The concept's Sources with the recorded deviations applied, given the server's real settings and sources. */
function mapSourcesConcept(reference, { settings, sources }) {
const [sample] = reference.sources;
const scan = sample.actions[0];
const rowAt = reference.surface.tree.indexOf('div.setting-row');
return {
...reference,
// The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove,
@@ -625,13 +639,15 @@ function mapSourcesConcept(reference, { settings, sources }) {
sources: sources.effective.map(source => ({ ...sample, title: source.label,
description: `${source.path} · ${WATCH_TEXT[source.watch]}`,
actions: [{ ...scan, text: WATCHED.has(source.watch) ? 'Scan changes' : 'Scan folder' }, { ...scan, text: 'Remove' }] })),
rows: reference.rows.map(row => {
if (row.control.id === 'monitoring') return { ...row, control: { ...row.control, checked: settings.monitoring_enabled } };
rows: reference.rows.flatMap(row => {
if (row.control.id === 'monitoring') return [{ ...row, control: { ...row.control, checked: settings.monitoring_enabled } },
foreignRunsRow(row, settings)];
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
return { ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } };
return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }];
}),
// D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.operation'] },
// D158: the inserted row after the first setting row; D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row',
...reference.surface.tree.slice(rowAt + 1), 'div.operation'] },
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
};
@@ -685,10 +701,12 @@ test('sources comparison detects mutations', async ({ page, request }) => {
const expected = mapSourcesConcept(concept, server);
const production = await productionSources(page, 'dark', { notice: true });
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs only in the real sources, the D98 operation and the D113 range.
// Unmapped, the concept differs only in the real sources, the D98 operation, the D158 row (which moves the D113
// range to the third row) and the D113 range.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort())
.toEqual(['operations.2', 'operations.length', 'rows.1', 'sources.0', 'sources.1', 'surface.tree'].sort());
.toEqual(['operations.2', 'operations.length', 'rows.1', 'rows.2', 'rows.length', 'sources.0', 'sources.1',
'surface.tree'].sort());
const measure = () => page.evaluate(measureSources, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed notice and a removed source row.
@@ -702,6 +720,13 @@ test('sources comparison detects mutations', async ({ page, request }) => {
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for outputs'; });
expect(await paths()).toEqual(['rows.0.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for new outputs'; });
// D158: the inserted row is compared too: a missing one and a changed label are caught.
const undoForeign = await mutate(page, { remove: ['#workspace-main .setting-row:has(#foreign-runs)'] });
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
await undoForeign();
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save other prompts'; });
expect(await paths()).toEqual(['rows.1.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save prompts from other workflows'; });
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = 'Add source'; });
expect(await paths()).toEqual(['heading.action.text']);
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = '+ Add source'; });
+16 -4
View File
@@ -363,14 +363,26 @@ test('a list read superseded by another read still opens a collection made since
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
// supersedes that one while both are held.
// A confirmed rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The
// timer's read supersedes that one while both are held. The rename's answer is held until "Fresh"'s read is, so
// the timer starts only then: with the answer let through at once, a slow host could fire the timer before the
// hash change, and the page would then wait on the timer's read alone (#459).
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await renameHeld;
return route.fallback();
});
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
await expect.poll(() => hold.held.length).toBe(1);
releaseRename();
// The rename landed (the sidebar row has the new name), so the count read is now due.
await expect(row(page, 'Landscape grids')).toHaveCount(1);
await expect.poll(() => hold.held.length).toBe(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
+19
View File
@@ -495,6 +495,25 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
// given one here to prove the shared render in History.
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
body.image_tags={state:'current',tags:['history_tag']};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
});
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
// nothing and shows no chip before.
+7 -4
View File
@@ -1,6 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
import { seedConcept, openConcept, openProduction, startInGrid } from './concept-inventory.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
@@ -814,6 +814,8 @@ test('leaving during Rescan cancels it without replacing the next workspace', as
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
// The approved Grid geometry (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1600,height:1100});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
@@ -895,7 +897,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
// D153 (#447): no "Highest rated", even with a rated prompt in the library.
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Oldest first', 'Name Z–A']);
const row = main.locator('.filter-row');
await expect(row.locator(':scope > *')).toHaveCount(5);
await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
@@ -914,8 +917,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await search(page).fill('');
await expect(cards(page)).toHaveCount(2);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
await sortBy(page).selectOption('rating');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await sortBy(page).selectOption('name-desc');
await expect(sortBy(page)).toHaveValue('name-desc');
await sortBy(page).selectOption('newest');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(1);
+25 -18
View File
@@ -9,8 +9,9 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
const editorDialog=page=>page.locator('dialog.library-editor');
test.beforeEach(async({page,request,browserName})=>{
@@ -65,9 +66,8 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
await expect(dialog.getByRole('region')).toHaveCount(0);
await expect(field(dialog,'Category')).toHaveValue('Portraits');
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
await expect(field(dialog,'Rating')).toHaveValue('4');
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
await expect(dialog.getByLabel('Rating')).toHaveCount(0);await expect(dialog.locator('select, #recipe-rating')).toHaveCount(0);
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
@@ -82,19 +82,20 @@ test('editor modes persist through reload with selected saved negative',async({p
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
await field(dialog,'Tags, separated by commas').fill('studio, moody');
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],notes:'Edited notes'});
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const saved=(await detail(request,f.positive_id)).positive;
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
// D153: the edit never sends a rating, so the stored 4 stays.
expect(f.metadata.rating).toBe(4);expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:4,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
await page.reload();await mountEditor(page);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await button(dialog,'Create text revision').click();
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
@@ -106,12 +107,15 @@ test('editor modes persist through reload with selected saved negative',async({p
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
parent_metadata:{title:'Edited portrait',category:'Uncategorized',notes:'Edited notes'}});
expect(Object.hasOwn(create.body.parent_metadata,'rating'),'D153: a revision sends no rating').toBe(false);
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
// D153: the revision is a new, unrated positive; the revised prompt keeps its stored rating.
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized',rating:null});
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
@@ -120,9 +124,9 @@ test('editor modes persist through reload with selected saved negative',async({p
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
expect(page.writes).toHaveLength(before);
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],notes:''}});
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
@@ -134,6 +138,7 @@ test('editor modes persist through reload with selected saved negative',async({p
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
await expect(page.locator('#editor-trigger')).toBeFocused();
expect((await detail(request,f.positive_id)).positive.rating,'D153: adding a negative keeps the stored rating').toBe(4);
});
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
@@ -804,20 +809,20 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await uniqueDotNames(page);
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
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 field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));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 .variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page,request})=>{
const f=page.fixture;await page.goto(`${workspace}#/library`);
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
await expect(toast(page)).toHaveText(UNAPPLIED);
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
@@ -825,6 +830,8 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
expect(page.writes).toHaveLength(1);
// D153: neither the reuse nor the kept draft touches the stored rating.
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
});
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
@@ -832,7 +839,7 @@ test('reuse with the stored parent metadata entered does not claim it was not ap
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
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 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('Saved as a new negative variant of an existing prompt.');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
@@ -848,13 +855,13 @@ test('reuse of an uncategorized prompt with the category left blank is not unapp
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
await field(dialog,'Category').fill(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 field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
};
await reuse('','blank category negative');
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
await reuse('Landscapes','different category negative');
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
await expect(toast(page)).toHaveText(UNAPPLIED);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
});
+22 -9
View File
@@ -152,13 +152,25 @@ test('failed library reads retry while late search responses cannot overwrite ne
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
await expect(cards(page)).toHaveCount(3);
await expect(page.getByRole('combobox',{name:'Sort results',exact:true})).toHaveValue('newest');
expect(sorts).toEqual(['newest']);
});
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']);
// D153 (#447): no "Highest rated".
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','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);
@@ -239,7 +251,8 @@ test('a sort change atomically discards old row geometry and renders changed par
} 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.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
// D153: a stored rating of 5 still draws no stars.
await expect(changed.locator('.card-footer > span')).not.toContainText('★');await expect(changed.locator('.card-footer > span > *')).toHaveCount(0);
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
@@ -445,8 +458,8 @@ test('Library cards clamp long prompts and keep real content inside the card',as
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
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`:[]);
// D153: no stars or "Rated N out of 5", whatever the stored rating.
await expect(footer.locator('> *')).toHaveCount(0);
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'}`);
}
@@ -598,7 +611,7 @@ async function patchShelf(page,patch) {
await route.fulfill({response,json:body});
});
}
/** The footer line as seen: everything but its visually hidden rating sentence. */
/** The footer line as seen: everything but visually hidden text (D153: there is no rating sentence any more). */
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');
@@ -634,13 +647,13 @@ test('card matches the concept and never wraps the category mid-word',async({pag
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');
// D153 (#447): the stored rating 5 draws no stars; the footer reads only the last use.
await expect(footer.locator('> span > *')).toHaveCount(0);
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.
// 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);
@@ -230,6 +230,31 @@ test('a view stored under older rules still lists and opens with its usable valu
await expect(promptCards(page)).toHaveCount(1);
});
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [] } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (['/prompt_manager/images/output', '/prompt_manager/library'].includes(url.pathname)) listings.push(url.searchParams.get('sort'));
});
await page.goto(`${workspace}#/saved/${images.id}`);
await expect(heading(page)).toHaveText('Rated cats');
await expect(sortBy(page)).toHaveValue('newest');
expect(await sortBy(page).locator('option').allTextContents()).not.toContain('Highest rated');
await expect(search(page)).toHaveValue('cat');
await expect(imageCards(page)).toHaveCount(1);
await savedNav(page).getByRole('link', { name: 'Rated dogs', exact: true }).click();
await expect(heading(page)).toHaveText('Rated dogs');
await expect(sortBy(page)).toHaveValue('newest');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
expect(listings.length).toBeGreaterThan(1);
expect(listings).not.toContain('rating');
});
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: [] } });
@@ -268,6 +268,71 @@ test('Watch for new outputs saves monitoring_enabled; a restart applies it', asy
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']);
});
test('D158: Save prompts from other workflows saves save_foreign_runs live; off while monitoring is off (#247)', async ({ page, request }, testInfo) => {
try {
const layout = await openSources(page);
const rows = layout.locator('section.surface > .setting-row');
await expect(rows.locator(':scope > div > label')).toHaveText(['Watch for new outputs', 'Save prompts from other workflows', 'Worker threads']);
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
const foreignRow = rows.filter({ has: page.locator('#foreign-runs') });
await expect(foreignRow.locator(':scope > div > p')).toHaveText('New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.');
await expect(foreign).toBeChecked();
await expect(foreign).toBeEnabled();
// Keyboard on desktop, a tap on the touch project.
if (testInfo.project.name === 'touch') await foreign.tap();
else {
await foreign.focus();
await page.keyboard.press('Space');
}
await expect(foreign).not.toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const response = await saved;
expect(response.request().postDataJSON()).toEqual(expect.objectContaining({ save_foreign_runs: false }));
expect((await response.json()).restart_required_fields).toEqual([]);
// Applied live: the monitor reads it for every file, so no restart is named.
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.save_foreign_runs).toBe(false);
// With Watch for new outputs drafted off nothing is watched: the row is disabled and its note says why.
const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true });
await monitoring.uncheck();
await expect(foreign).toBeDisabled();
const note = foreignRow.locator('p.small');
await expect(note).toHaveText('Applies while Watch for new outputs is on.');
await expect(foreign).toHaveAttribute('aria-describedby', await note.getAttribute('id'));
await monitoring.check();
await expect(foreign).toBeEnabled();
await expect(note).toHaveCount(0);
await expect(foreign).not.toHaveAttribute('aria-describedby', /./);
await expect(statusOf(page)).toHaveText('No unsaved changes.');
} finally {
// The server is shared by the spec's tests: put the default back whatever happened.
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
}
});
test('D158: saved off while the monitor still watches until a restart, the foreign row can still be set (D128)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK();
try {
const layout = await openSources(page);
await layout.getByRole('switch', { name: 'Watch for new outputs', exact: true }).uncheck();
await saveButton(page).click();
await expect(sourceList(page).locator('.operation p').first()).toHaveText('output · watching until restart');
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
await expect(foreign).toBeEnabled();
await expect(layout.locator('.setting-row').filter({ has: page.locator('#foreign-runs') }).locator('p.small'))
.toHaveText('Applies while Watch for new outputs is on.');
await foreign.uncheck();
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
expect((await saved).postDataJSON()).toEqual({ save_foreign_runs: false, expected_revision: expect.any(String) });
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
} finally {
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
}
});
test('worker range max is cpu_count', async ({ page, request }) => {
const { settings } = await serverJson(request, SETTINGS_URL);
const layout = await openSources(page);
+111 -15
View File
@@ -79,7 +79,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
]);
expect(writes).toEqual([]);
});
@@ -373,13 +373,12 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
['Rated images', number(statistics.rated_images)]]);
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
@@ -481,17 +480,19 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await openAppearance(page);
await expect(page.locator('#default-view')).toHaveValue('grid');
await page.locator('#default-view').selectOption('masonry');
// #451: a fresh browser's Default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await page.locator('#default-view').selectOption('grid');
await page.locator('#density').selectOption('compact');
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['grid', 'compact']);
// Back in Images (the page session's last visit): the toolbar's List is the current view's own and stays; the
// size the toolbar never changed follows the newer default (#451).
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
// The saved view opens as before.
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(imageCards(page).first()).toBeVisible();
@@ -501,15 +502,109 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await openImages(page);
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Grid', exact: true }))
.toHaveAttribute('aria-pressed', 'true');
} finally {
await request.delete(`/prompt_manager/saved-views/${view.id}`);
}
});
/** Choose a Default layout in System › Appearance and save it. */
async function saveDefaultLayout(page, value) {
await openAppearance(page);
await page.locator('#default-view').selectOption(value);
await saveAppearance(page, 'Saved in this browser.');
}
const imageLayoutOf = page => page.locator('#workspace-main .image-grid');
const layoutButton = (page, name) => page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name, exact: true });
test('a fresh browser opens Images in Masonry (#451)', async ({ page }) => {
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
// Nothing was stored for it: the default is the gallery settings' own.
expect(await page.evaluate(() => localStorage.getItem('gallerySettings'))).toBeNull();
// A stored explicit choice is kept.
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' })));
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
});
test('a Default layout saved later reaches the next Images visit in this tab (#451)', async ({ page }) => {
// The visit starts from Grid, then System saves Masonry without a reload.
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await page.locator('#system-nav a[data-route="system"]').click();
await expect(page.locator('#workspace-main .settings-layout')).toBeVisible();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(page.locator('#timeout')).toBeEnabled();
await page.locator('#default-view').selectOption('masonry');
await saveAppearance(page, 'Saved in this browser.');
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
});
test('a Default layout saved in another tab reaches this tab\'s next Images visit (#451)', async ({ page, context }) => {
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
const other = await context.newPage();
try {
await saveDefaultLayout(other, 'list');
} finally {
await other.close();
}
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
// A toolbar choice afterwards is this view's own again: a return keeps it.
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
});
// #451 D157: a cover crop keeps a portrait image's top; Masonry shows the whole image and keeps the centre.
test('cover crops keep the top of a portrait image; Masonry stays centred (#451)', async ({ page }) => {
const position = locator => locator.evaluate(node => getComputedStyle(node).objectPosition);
const portrait = page.locator('#workspace-main article.media-card .image-open img[width="1024"][height="1536"]').first();
await openImages(page);
await layoutButton(page, 'Grid').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await expect(portrait).toBeVisible();
expect(await position(portrait)).toBe('50% 20%');
await layoutButton(page, 'List').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
expect(await position(portrait)).toBe('50% 20%');
// The side panel's cover (also History's media inspector).
await portrait.locator('xpath=ancestor::article[1]').locator('.image-card-actions button').first().click();
await expect(panel(page).locator('.detail-cover')).toBeVisible();
expect(await position(panel(page).locator('.detail-cover'))).toBe('50% 20%');
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
expect(await position(portrait)).toBe('50% 50%');
// The Library's card art and a prompt history's shared gallery.
await page.goto(`${workspace}#/library`);
const art = page.locator('#workspace-main .prompt-card .card-art img').first();
await expect(art).toBeVisible();
expect(await position(art)).toBe('50% 20%');
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
await expect(imageCards(page).first()).toBeVisible();
expect(await position(imageCards(page).first().locator('.image-open img'))).toBe('50% 20%');
});
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
await openAppearance(page);
await page.locator('#default-view').selectOption('list');
@@ -787,12 +882,13 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
await expect(page.locator('#default-view')).toHaveValue('list');
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
await reset.click();
await expect(page.locator('#default-view')).toHaveValue('grid');
// #451 D157: the default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await expect(page.locator('#video-muted')).toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'masonry',
videoMute: true, showImageInfo: true });
});
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
test.beforeEach(async ({ page, request, browserName }) => {
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
await expect(bar(page).locator('strong')).toHaveText('2 selected');
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
// The finished job's files are read again: the selected card's panel shows its new image tags.
await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
expect(await savedTags(request, UNLINKED)).toEqual([]);
});
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
// The reviewed file's panel shows its saved tags without reselecting.
await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
});
+62 -20
View File
@@ -458,27 +458,32 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
expect(await count()).toBe(mounted);
});
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
// Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// D21/D39: the linked recipe's own tag, plain.
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
// D21/D39: the linked recipe's own tag, plain, and no image tags yet.
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
// The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
@@ -490,9 +495,10 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
@@ -502,18 +508,53 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const row = details.locator('.tags').last();
const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
});
// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
// so they never read as part of it; the same in the side panel and the viewer details.
async function tagSectionPlacement(root) {
return root.evaluate(node => {
const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
return {
after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
};
});
}
const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
await page.goto(images);
const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(details)).toEqual(PLACED);
});
test('search finds an image by its image tag', async ({ page, request }) => {
@@ -570,7 +611,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await page.evaluate(() => {
const node = document.querySelector('aside[aria-label="Selected image details"]');
window.__loadingSeen = false;
@@ -589,12 +630,13 @@ test('the post-save re-read keeps the panel content until the new read lands', a
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
// While the re-read is held, the panel still shows the file's details.
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
const landed = inspected(page, FIRST);
release();
await landed;
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
await page.unroute(matcher);
});
@@ -607,7 +649,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
await infoButton(page, SECOND).click();
await read;
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
// The newest match (SECOND) is selected once the filter applies.
@@ -767,7 +809,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
@@ -792,7 +834,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await openTag(page, SECOND, { goto: false });
+3
View File
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
@@ -415,6 +416,8 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
// The Grid's footer reservations (D157: the default layout is Masonry).
await startInGrid(page);
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(24);
+13 -9
View File
@@ -263,15 +263,18 @@ test.describe('public chronological history with the actual acknowledged host br
});
});
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
@@ -369,7 +372,7 @@ test('successful source capture survives downstream failure and real interruptio
}
});
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
const first = await submit(execution.request, graph);
await history(execution.request, first);
@@ -377,8 +380,9 @@ test('cached dynamic raw stays unknown while literal affixes and effective cache
const second = await submit(execution.request, graph);
await history(execution.request, second);
const value = (await capture(execution, second)).contexts[0];
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw.provenance).toBe('unknown');
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
});
+20 -2
View File
@@ -287,6 +287,14 @@ async function capturedContext(execution, id) {
return result.captures[result.rows[0].run_id].contexts[0];
}
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
async function expectTrackedRuns(execution, promptId, expected) {
await expect.poll(async () => {
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
return response.ok() ? (await response.json()).recorded_run_count : null;
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
}
async function firstRun(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const built = await build(page, { ...testCase, prefix });
@@ -309,6 +317,8 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
const counts = await runCounts(execution, first.context);
expect(counts.positive).toBe(3);
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
// #403: the cached jobs are tracked runs of the prompt too
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
});
}
@@ -329,6 +339,7 @@ test.describe('cached runs in one renderer (#347)', () => {
else expect(again.cached).toContain(first.promptNode);
}
expect((await runCounts(execution, first.context)).positive).toBe(3);
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
}
@@ -352,6 +363,9 @@ test.describe('cached runs in one renderer (#347)', () => {
const bContext = await capturedContext(execution, second.id);
expect(bContext.positive.raw).toBe(b);
expect((await runCounts(execution, bContext)).positive).toBe(1);
// #403: the third job reuses A's execution, not the newest (B's)
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
@@ -390,10 +404,14 @@ test.describe('cached runs in one renderer (#347)', () => {
} });
expect(rewired.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(1);
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
expect(cachedContext.positive_prompt_id).toBeNull();
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
expect((await runCounts(execution, first.context)).positive).toBe(2);
// The rewired job used the prompt as its negative: neither number counts it
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
});
});
+9 -2
View File
@@ -334,8 +334,15 @@ async def image_monitor(request: web.Request) -> web.Response:
@PromptServer.instance.routes.get("/pm_execution_fixture/monitor")
async def image_monitor_state(request: web.Request) -> web.Response:
"""File names of keyless images waiting for their run's recorded outputs."""
"""File names of images waiting for their run's recorded outputs: keyless
ones (``awaiting``) and foreign ones (``foreign``, #247)."""
monitor = _plugin_module("utils.image_monitor")._monitor_instance
handler = getattr(monitor, "handler", None)
waiting = handler.awaiting_history() if handler is not None else []
return web.json_response({"awaiting": [os.path.basename(p) for p in waiting]})
foreign = handler.awaiting_foreign() if handler is not None else []
return web.json_response(
{
"awaiting": [os.path.basename(p) for p in waiting],
"foreign": [os.path.basename(p) for p in foreign],
}
)
+64 -1
View File
@@ -1,6 +1,7 @@
// #342 on the real pinned host: the image monitor links an output only to a prompt
// that provably produced it. An unknown node (PMHistoryCached) stands in for the
// maintainer's LLM; KSampler is the fixture's deterministic stand-in.
// maintainer's LLM; KSampler is the fixture's deterministic stand-in. #247: an output
// of a workflow without a PromptManager node gets its own prompt.
import { randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
@@ -141,6 +142,68 @@ test('an output saved without metadata links once its recorded run names a store
}
});
// #247: no PromptManager node at all, only ComfyUI's own text encoders, sampler and
// saver (the fixture's inputs and decoder stand in for the model files).
function foreignGraph({ text, seed = 1, prefix }) {
return {
'10': { class_type: 'PMHistoryInputs', inputs: {} },
'20': { class_type: 'CLIPTextEncode', inputs: { text, clip: ['10', 0] } },
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
'40': { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2], positive: ['20', 0],
negative: ['30', 0], seed, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
'60': { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: ['40', 0] } },
'70': { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: prefix } },
};
}
test('an output of a workflow without a PromptManager node creates and links its prompt; a second run reuses it', async ({ execution }, testInfo) => {
const text = `foreign lighthouse at dusk ${testInfo.project.name} ${randomUUID()}`;
const first = await saved(execution.request, foreignGraph({ text, prefix: 'foreign/run' }));
await linkedTo(execution, first, text);
// Only the seed changes: a new file, the same text, so the same stored prompt.
const second = await saved(execution.request, foreignGraph({ text, seed: 2, prefix: 'foreign/run' }));
await linkedTo(execution, second, text);
const state = await readLinks(execution.databasePath, [first, second], [text]);
expect(state).toEqual({ links: { [first]: text, [second]: text }, stored: { [text]: true } });
// Decided from the graph at settle: nothing waits for the run's recorded outputs.
expect((await (await execution.request.get('/pm_execution_fixture/monitor')).json()).foreign).toEqual([]);
});
// CUJ 1: two branches saved under one prefix, so neither the graph nor the file name
// names a file's branch; the run's recorded saver does, once its outputs are sealed.
function sharedPrefixGraph(texts) {
const nodes = {
'10': { class_type: 'PMHistoryInputs', inputs: {} },
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
};
texts.forEach((text, index) => {
const base = (index + 1) * 100;
nodes[String(base)] = { class_type: 'CLIPTextEncode', inputs: { text, clip: ['10', 0] } };
nodes[String(base + 1)] = { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
positive: [String(base), 0], negative: ['30', 0], seed: index + 1, steps: 1, cfg: 1, sampler_name: 'euler',
scheduler: 'normal', denoise: 1 } };
nodes[String(base + 2)] = { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: [String(base + 1), 0] } };
nodes[String(base + 3)] = { class_type: 'SaveImage', inputs: { images: [String(base + 2), 0], filename_prefix: 'foreign/dataset' } };
});
return nodes;
}
test('each branch of a shared-prefix workflow without a PromptManager node links to its own prompt after the run', async ({ execution }, testInfo) => {
const tag = `${testInfo.project.name} ${randomUUID()}`;
const texts = [`dataset branch one harbor ${tag}`, `dataset branch two meadow ${tag}`];
const id = randomUUID();
const response = await execution.request.post('/prompt', { data: { prompt: sharedPrefixGraph(texts), prompt_id: id } });
expect(response.ok()).toBe(true);
let item;
await expect.poll(async () => {
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
return item?.status?.messages?.some(([kind]) => kind === 'execution_success') || false;
}, { timeout: 15000 }).toBe(true);
const files = ['103', '203'].map(node => item.outputs[node].images[0].filename);
for (const [index, file] of files.entries()) await linkedTo(execution, file, texts[index]);
expect((await (await execution.request.get('/pm_execution_fixture/monitor')).json()).foreign).toEqual([]);
});
// #332 on the real host: the migration ran at startup and the image category routes
// set, list and remove an own category on a generated output.
test('an output gets its own image category and loses it again', async ({ execution }, testInfo) => {
+1
View File
@@ -192,6 +192,7 @@
"settings.monitoring_enabled",
"settings.result_timeout",
"settings.safety_backup",
"settings.save_foreign_runs",
"settings.thumbnail_edge",
"settings.thumbnail_format",
"settings.thumbnail_quality",
+5 -2
View File
@@ -64,12 +64,15 @@ test("infinite scrolling is off by default and must be a boolean", () => {
test("the workspace keys have their own defaults and validation", () => {
const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js");
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid");
// #451: Images opens in Masonry unless the browser chose otherwise.
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "masonry");
assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable");
assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]);
assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]);
assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid");
assert.equal(normalizeGallerySettings({ workspaceLayout: "grid" }).workspaceLayout, "grid");
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings(null).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large");
assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable");
// The legacy gallery's own view mode is separate: it has no masonry.
+62 -5
View File
@@ -209,16 +209,18 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
test('renders current image tags before linked tags without duplicates', async () => {
test('renders current image tags before linked tags, each without repeats within its source', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
// D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
[{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
{name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
[{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
@@ -236,6 +238,61 @@ test('none and unavailable render no image chips', async () => {
}
});
// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
// prompt blocks, so per-image tags never read as part of the negative prompt.
function renderVariant(record, variant, options = {}) {
const f = mountedDom(), scope = createScope();
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
view.setState({loading:false, error:false, record});
return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
}
const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
function sectionSummary(f, section) {
const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
}
test('chips name their source: the image or the linked prompt', async () => {
const {inspectorTagChips, tagSectionLabel} = await load();
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
['image', 'image', 'linked', 'linked', 'linked']);
assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
});
test('image tags and linked tags render in their own labelled sections', async () => {
for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
try {
const sections = tagSections(f).map(section => sectionSummary(f, section));
assert.deepEqual(sections, [
{className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
{className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
], variant);
// No bare chip row follows the prompt blocks any more.
const inner = f.container.children.find(n => n.tagName === 'div');
assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
} finally { done(); }
}
});
test('a recipe names its tags and the context names the regions', async () => {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
try {
assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
{className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
{className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
]);
} finally { done(); }
});
test('an empty tag source draws no section', async () => {
let view = renderVariant(tagged(null), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
});
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
+34 -5
View File
@@ -211,11 +211,12 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
});
test('stream sends category, tags and the rating sort and validates them', async () => {
test('stream sends category, tags and the sort and validates them; the rating sort is gone (D153)', 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({q:'cat',category:'Animals',tags:['cats','studio'],sort:'name-desc'});
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'name-desc');
await assert.rejects(stream.reset({sort:'rating'}),/sort/i);
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);
@@ -277,7 +278,7 @@ test('the caption date names the newest result day under Newest first only', asy
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),'');
for(const sort of ['name','name-desc']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
assert.equal(captionDate([],'newest',now),'');
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
});
@@ -525,7 +526,35 @@ test('defaults apply to a new visit but not to the current view or a saved view'
assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' });
// Without readable preferences nothing changes.
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
// #451: a preference object without a layout falls back to the default layout, Masonry.
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'masonry', density: 'comfortable' });
});
test('a default saved after the last visit started reaches the next visit unless the toolbar changed it (#451)', async () => {
const { initialImagesView } = await source;
const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
const started = { layout: 'grid', density: 'comfortable' };
// The visit kept the defaults it started from: the newer defaults win.
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started }, preferences }),
{ layout: 'masonry', density: 'compact' });
// A toolbar choice is the current view's own, whatever the defaults became; each value on its own.
assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'comfortable', defaults: started }, preferences }),
{ layout: 'list', density: 'compact' });
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'large', defaults: started }, preferences }),
{ layout: 'masonry', density: 'large' });
// Unchanged defaults: the visit returns as it was.
assert.deepEqual(initialImagesView({ prior: { layout: 'masonry', density: 'compact', defaults: { layout: 'masonry', density: 'compact' } }, preferences }),
{ layout: 'masonry', density: 'compact' });
// A saved view and an embedded gallery never get a prior; without preferences the prior is kept.
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started } }),
{ layout: 'grid', density: 'comfortable' });
});
test('the defaults a visit starts from are the fresh-visit defaults (#451)', async () => {
const { visitDefaults } = await source;
assert.deepEqual(visitDefaults({ workspaceLayout: 'list', workspaceDensity: 'large' }), { layout: 'list', density: 'large' });
assert.deepEqual(visitDefaults({}), { layout: 'masonry', density: 'comfortable' });
assert.deepEqual(visitDefaults(null), { layout: 'grid', density: 'comfortable' });
});
test('listingFilter is the listing query as an object, without paging (#392)', async () => {
+64 -20
View File
@@ -117,8 +117,9 @@ test('new prompt from Images waits for a fresh options epoch', async () => {
assert.equal(sentBody.expected_epoch, EPOCH);
assert.deepEqual(sentBody, {
expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' },
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], notes: '' },
});
assert.equal(Object.hasOwn(sentBody.parent_metadata, 'rating'), false, 'D153: a new prompt sends no rating');
assert.equal(controller.state.phase, 'complete');
assert.equal(committed.length, 1);
assert.equal(committed[0].kind, 'create');
@@ -230,11 +231,11 @@ test('editor recovery preserves hyphenated ApiError codes', async () => {
const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('rating', 5);
controller.setField('notes', 'kept');
await controller.submit();
phases.push(controller.state.phase);
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
assert.equal(controller.state.draft.rating, 5, 'draft preserved');
assert.equal(controller.state.draft.notes, 'kept', 'draft preserved');
assert.equal(controller.state.receipt, null);
controller.dispose();
}
@@ -306,7 +307,7 @@ test('confirmed save refresh retry is read only', async () => {
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let failReads = 1;
const committed = [], reconciled = [];
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) };
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext', edit_token: TOKEN2 }) };
const base = library({ write: () => response(200, receipt) });
const server = harness(call => {
if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline'));
@@ -315,7 +316,6 @@ test('confirmed save refresh retry is read only', async () => {
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
await controller.initialize();
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
await controller.submit();
assert.equal(controller.state.phase, 'saved-refresh-error');
@@ -338,7 +338,7 @@ test('confirmed save refresh retry is read only', async () => {
test('legacy title fallback is not an edit', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) };
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext' }) };
const server = harness(library({ write: () => response(200, receipt) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
@@ -346,7 +346,6 @@ test('legacy title fallback is not an edit', async () => {
assert.equal(controller.state.baseline.title, null, 'stored title stays distinct');
assert.equal(controller.state.draft.tags, 'soft, warm');
assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative');
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
controller.setField('tags', 'warm, soft,');
controller.setField('category', ' Studio ');
@@ -357,7 +356,7 @@ test('legacy title fallback is not an edit', async () => {
const sentBody = write.body;
assert.equal(sentBody.expected_epoch, EPOCH);
assert.equal(sentBody.if_match, TOKEN);
assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' });
assert.deepEqual(sentBody.metadata, { notes: 'keep\ntext' });
const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await unchanged.initialize();
@@ -421,12 +420,12 @@ test('metadata conflict reload keeps changed fields for review and never replays
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } })
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, notes: 'mine' } })
: response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('rating', '5');
owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 });
controller.setField('notes', 'mine');
owner = positive({ title: 'Renamed elsewhere', category: 'Elsewhere', edit_token: TOKEN2 });
await controller.submit();
assert.equal(controller.state.phase, 'conflict');
await controller.submit();
@@ -434,20 +433,20 @@ test('metadata conflict reload keeps changed fields for review and never replays
await controller.reloadOwner({ keepDraft: true });
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.reviewRequired, true);
assert.equal(controller.state.draft.rating, 5, 'changed field kept');
assert.equal(controller.state.draft.notes, 'mine', 'changed field kept');
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
assert.equal(controller.state.draft.notes, 'other writer');
assert.equal(controller.state.draft.category, 'Elsewhere');
assert.equal(server.writes().length, 1);
await controller.submit();
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } });
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { notes: 'mine' } });
assert.equal(controller.state.phase, 'complete');
controller.dispose();
const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await discard.initialize();
discard.setField('rating', 1);
discard.setField('notes', 'dropped');
await discard.reloadOwner({ keepDraft: false });
assert.equal(discard.state.draft.rating, 3);
assert.equal(discard.state.draft.notes, '');
assert.equal(discard.state.reviewRequired, false);
discard.dispose();
});
@@ -457,12 +456,12 @@ test('metadata unknown outcome retry reloads the owner instead of replaying', as
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: () => {
owner = positive({ rating: 5, edit_token: TOKEN2 });
owner = positive({ notes: 'applied', edit_token: TOKEN2 });
return Promise.reject(new TypeError('Failed to fetch'));
} })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('rating', 5);
controller.setField('notes', 'applied');
await controller.submit();
assert.equal(controller.state.phase, 'unknown-outcome');
await controller.retryUnconfirmed();
@@ -570,8 +569,10 @@ test('revision seeds text from the selected pair and sends Uncategorized for a b
await controller.submit();
assert.deepEqual(server.writes()[0].body, {
expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' },
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], notes: 'n' },
});
// D153: the revision's new positive is unrated; the owner's stored rating (3) is neither copied nor written.
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
controller.dispose();
});
@@ -589,7 +590,9 @@ test('invalid drafts and server failures keep the draft without a receipt', asyn
assert.equal(controller.state.phase, 'ready');
assert.match(controller.state.error.message, /Title/);
assert.throws(() => controller.setField('unknown', 'x'), TypeError);
assert.throws(() => controller.setField('rating', 7), TypeError);
// D153: the editor has no rating field.
assert.throws(() => controller.setField('rating', 4), /Unknown editor field: rating/);
assert.throws(() => controller.setField('notes', 4), TypeError);
controller.setField('title', 'T');
await controller.submit();
assert.equal(controller.state.phase, 'write-error');
@@ -807,3 +810,44 @@ test('a blank category reused against an uncategorized owner is not unapplied me
controller.dispose();
}
});
// D153 (#447): the workspace no longer edits the 1–5 rating, and a save never clears a stored one.
test('editing a prompt rated 4 sends no rating and the saved prompt keeps 4', async () => {
const { createLibraryEditorController, EDITOR_FIELDS } = await source;
assert.deepEqual(EDITOR_FIELDS, ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
let owner = positive({ rating: 4, title: 'Portrait' });
const server = harness(call => library({ owner, write: write => {
owner = positive({ ...owner, ...write.body.metadata, edit_token: TOKEN2 });
return response(200, { success: true, library_epoch: EPOCH, positive: owner });
} })(call));
const reconciled = [];
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onReconciled: result => reconciled.push(result) });
await controller.initialize();
assert.equal(Object.hasOwn(controller.state.draft, 'rating'), false, 'the draft has no rating');
assert.equal(Object.hasOwn(controller.state.baseline, 'rating'), false, 'the baseline has no rating');
controller.setField('title', 'Portrait, edited');
await controller.submit();
const [write] = server.writes();
assert.deepEqual(write.body.metadata, { title: 'Portrait, edited' });
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.receipt.positive.rating, 4);
assert.equal(reconciled[0].detail.positive.rating, 4, 'the re-read prompt still has its rating');
controller.dispose();
});
test('a reused rated positive is not reported as unapplied metadata for its rating', async () => {
const { createLibraryEditorController } = await source;
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: 4, notes: '' }) });
const server = harness(created(() => response(200, reused)));
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
await controller.initialize();
controller.setField('title', 'Portrait');
controller.setField('positive_text', 'a portrait');
await controller.submit();
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.outcome.metadataNotApplied, false);
controller.dispose();
});
+4 -5
View File
@@ -46,9 +46,8 @@ test('used label boundaries in UTC and local time', async () => {
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: '' });
// D153 (#447): cards show no rating, so the layout module has no rating label.
test('the layout module offers no rating label', async () => {
const layout = await source;
assert.equal(Object.hasOwn(layout, 'ratingLabel'), false);
});
+8 -2
View File
@@ -5,8 +5,8 @@ const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,
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();
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'name'}),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'),'name');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}];
@@ -114,3 +114,9 @@ test('the stream keeps the first page\'s storage epoch and refuses a later page
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
});
test('the shelf no longer sorts by rating (D153)',async()=>{
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
});
+2 -2
View File
@@ -44,7 +44,7 @@ test('normalizes new workspace keys and keeps legacy keys', () => {
// Invalid workspace values fall back to the defaults, as every legacy key does.
store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' });
const fallback = module.readPreferences(() => store).values;
assert.equal(fallback.workspaceLayout, 'grid');
assert.equal(fallback.workspaceLayout, 'masonry'); // #451: the default layout
assert.equal(fallback.workspaceDensity, 'comfortable');
assert.equal(fallback.videoMute, true);
// The legacy normalizer knows the new keys and still keeps unknown ones.
@@ -94,7 +94,7 @@ test('denied storage reports tab-only', () => {
module.resetTabPreferences();
const read = module.readPreferences(denied);
assert.equal(read.persisted, false);
assert.equal(read.values.workspaceLayout, 'grid');
assert.equal(read.values.workspaceLayout, 'masonry');
const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied);
assert.deepEqual(result, { persisted: false, conflicts: [] });
// The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it.
+12 -2
View File
@@ -72,9 +72,9 @@ test('the listing keeps usable views in order and normalizes their state', async
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, collection: '' };
const filters = { q: 'castle', category: 'Portraits', sort: 'name-desc', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
// #331 D140: the open collection round-trips; a missing one is ''.
@@ -215,3 +215,13 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
assert.deepEqual(api.calls, []);
});
test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => {
const { normalizeViewState, imagesFilters, libraryFilters } = await source;
assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('library', { sort: 'rating' }).sort, 'newest');
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
});
+2 -1
View File
@@ -162,7 +162,8 @@ test('reset restores defaults without touching other keys', async () => {
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
assert.equal(reset.textContent, 'Reset');
reset.click();
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
// #451: the default layout is Masonry.
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
// The host fields and every key outside the display defaults are left as they are.
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
+3 -3
View File
@@ -141,7 +141,7 @@ test('the report summary says when it was checked and whether it is complete', a
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
});
test('statistics rows are the real runs and ratings, then the library totals', async () => {
test('statistics rows are the real runs, then the library totals, without ratings (D153)', async () => {
const { categoryRows, totalsRows } = await overviewModule;
const statistics = {
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
@@ -149,8 +149,8 @@ test('statistics rows are the real runs and ratings, then the library totals', a
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
+17
View File
@@ -157,3 +157,20 @@ test('a valid configured source missing from the effective list is another spell
assert.deepEqual(sourceRows({ sources, saved, current: saved }).rows.at(-1), { key: 'output/.', title: 'output',
description: 'output/. · same folder as another source', scan: null, remove: 'output/.' });
});
test('D158: "Save prompts from other workflows" drafts save_foreign_runs, disabled while monitoring is off (#247)', async () => {
const { FOREIGN_RUNS, foreignRunsState } = await sourcesModule;
assert.deepEqual(FOREIGN_RUNS, { id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows',
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.',
off: 'Applies while Watch for new outputs is on.' });
assert.deepEqual(foreignRunsState({ value: true, monitoring: true }), { disabled: false, checked: true, note: null });
assert.deepEqual(foreignRunsState({ value: false, monitoring: true }), { disabled: false, checked: false, note: null });
// Monitoring drafted or saved off: nothing is watched, so the row says why it is disabled and keeps its value.
assert.deepEqual(foreignRunsState({ value: true, monitoring: false }), { disabled: true, checked: true, note: FOREIGN_RUNS.off });
// Not read yet (or the host read failed): disabled without a value, the host rows' own note applies.
assert.deepEqual(foreignRunsState({ value: undefined, monitoring: undefined }), { disabled: true, checked: null, note: null });
assert.deepEqual(foreignRunsState({ value: undefined, monitoring: false }), { disabled: true, checked: null, note: null });
// Saved off while the running monitor still watches until the restart (D128 `stopping`): it can still be set.
assert.deepEqual(foreignRunsState({ value: true, monitoring: false, stopping: true }),
{ disabled: false, checked: true, note: FOREIGN_RUNS.off });
});
+1
View File
@@ -85,6 +85,7 @@ CONFIG = {
"max_image_age_days",
"max_concurrent_processing",
"metadata_extraction_timeout",
"save_foreign_runs",
},
"integrations": {"lora_manager_enabled", "has_civitai_api_key"},
}
+193 -1
View File
@@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase):
return counts
class TestReceipts(unittest.TestCase):
class ReceiptCase(unittest.TestCase):
def setUp(self):
self.context = ("j1", None)
self.runs = CachedRuns(
@@ -686,6 +686,8 @@ class TestReceipts(unittest.TestCase):
def keys(self):
return list(self.runs._receipts)
class TestReceipts(ReceiptCase):
def test_least_recently_used_run_keys_are_dropped(self):
self.begin(1)
for name in ("a", "b", "c"):
@@ -726,6 +728,196 @@ class TestReceipts(unittest.TestCase):
self.assertEqual(list(self.runs._attempt.nodes), ["b"])
class TestRecordedFields(ReceiptCase):
"""The texts a receipt keeps for the history capture of a cached job (#403)."""
F = (("raw a", " pre ", ""),)
def record(self, item_id, name="a", output="x", fields=F, facts=None):
self.begin(item_id)
self.runs.record_item(name, (output,), facts or Facts("h"), fields)
self.runs.end_attempt(item_id)
def lookup(self, name="a", values=(("x",),), prompt_id="j9", item_id=9):
self.begin(item_id, prompt_id)
return self.runs.recorded_fields(prompt_id, name, values)
def test_a_cached_job_reads_the_fields_of_the_execution_it_reuses(self):
self.record(1)
self.assertEqual(self.lookup(), [self.F])
def test_list_items_return_their_own_fields_in_order(self):
self.begin(1)
for index, text in enumerate(("x", "y")):
self.context = ("j1", index)
self.runs.record_item("a", (text,), Facts("h"), ((text, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(
self.lookup(values=(("x", "y"),)), [(("x", "", ""),), (("y", "", ""),)]
)
def test_fails_closed(self):
self.record(1)
for case, kwargs in (
("other output", dict(values=(("changed",),))),
("other node", dict(name="b")),
("unknown node", dict(name="zzz")),
("no values", dict(values=())),
("malformed values", dict(values="x")),
):
with self.subTest(case=case):
self.assertIsNone(self.lookup(**kwargs))
with self.subTest(case="another job"):
self.begin(9, "j9")
self.assertIsNone(self.runs.recorded_fields("j8", "a", (("x",),)))
with self.subTest(case="no job"):
self.runs.end_attempt(9)
self.assertIsNone(self.runs.recorded_fields("j9", "a", (("x",),)))
def test_an_execution_without_fields_gives_none(self):
for fields in (None, (("a", "b"),), ((1, "", ""),), (), "abc"):
with self.subTest(fields=fields):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=fields)
self.assertIsNone(self.lookup())
def test_a_lookup_never_counts_or_marks_the_node_replayed(self):
self.record(1)
self.lookup()
self.assertFalse(self.runs.replayed("a"))
def test_equal_outputs_with_other_raw_text_conflict_and_still_count(self):
key = prompt_node_key(self.g, "a")
self.record(1, fields=(("x ", "", ""),))
self.record(2, fields=(("x", "", ""),))
# Counting dedupes by outputs as before; the fields cannot name either
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertIsNone(self.lookup())
self.assertIsNotNone(self.runs._match(key, (("x",),)))
# The conflict stays: a later execution cannot tell which text it was
self.record(3, fields=(("x", "", ""),))
self.assertIsNone(self.lookup())
def test_equal_outputs_with_other_facts_still_count_the_newest(self):
key = prompt_node_key(self.g, "a")
self.record(1, facts=Facts("old"), fields=(("a x", "", ""),))
self.record(2, facts=Facts("new"), fields=(("x", "a ", ""),))
items = self.runs._match(key, (("x",),))
self.assertEqual([item.facts for item in items], [Facts("new")])
def test_equal_outputs_and_fields_still_replace(self):
key = prompt_node_key(self.g, "a")
self.record(1)
self.record(2)
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertEqual(self.lookup(), [self.F])
def test_fields_dropped_by_the_budget_do_not_conflict(self):
self.runs = CachedRuns(context=lambda: self.context, max_field_bytes=0)
self.record(1)
self.runs._max_field_bytes = 100
self.record(2)
self.assertEqual(self.lookup(), [self.F])
def test_oversized_or_invalid_fields_are_not_kept(self):
for raw in ("x" * (module.MAX_FIELD_BYTES + 1), "\ud800"):
with self.subTest(size=len(raw)):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=((raw, "", ""),))
self.assertIsNone(self.lookup())
self.assertEqual(self.runs._field_bytes, 0)
def test_old_receipts_give_up_their_fields_to_new_ones(self):
self.runs = CachedRuns(
context=lambda: self.context, max_keys=3, max_field_bytes=10
)
self.record(1, "a", fields=(("123456", "", ""),))
self.record(2, "b", fields=(("123456", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertIsNone(self.lookup("a"))
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
# "a" still counts
self.assertIsNotNone(self.runs._match(prompt_node_key(self.g, "a"), (("x",),)))
# Larger than the whole budget: not kept, nothing else given up
self.record(3, "c", fields=(("12345678901", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
def test_one_receipt_holds_at_most_one_runs_history(self):
with mock.patch.object(module, "MAX_RECEIPT_FIELD_BYTES", 8):
self.begin(1)
for index, raw in enumerate(("12345", "12345")):
self.context = ("j1", index)
self.runs.record_item("a", (raw,), Facts("h"), ((raw, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(self.runs._field_bytes, 5)
self.assertIsNone(self.lookup(values=(("12345", "12345"),)))
def test_the_byte_count_follows_replacement_and_eviction(self):
self.begin(1)
self.runs.record_item("a", ("x",), Facts("h"), (("12345", "", ""),))
self.runs.record_item("a", ("x",), Facts("h"), (("123", "", ""),))
self.assertEqual(self.runs._field_bytes, 3)
self.runs.end_attempt(1)
for item_id, raw in ((2, "1"), (3, "22"), (4, "333")):
self.record(item_id, output=raw, fields=((raw, "", ""),))
# max_receipts=2: only the receipts of jobs 4 and 3 remain
self.assertEqual(self.runs._field_bytes, 5)
for item_id, name in ((5, "b"), (6, "c")): # max_keys=2: "a" is dropped
self.record(item_id, name, fields=(("1", "", ""),))
self.assertEqual(self.runs._field_bytes, 2)
self.assertEqual(
self.runs._field_bytes,
sum(module._bytes(r) for r in self.runs._receipts.values()),
)
class TestCachedJobsRecordFields(JobCase):
"""The real nodes keep the texts they gave the history capture (#403)."""
def lookup(self, g, node_id="pm", prompt_id="job-x"):
self.item_id += 1
self.runs.begin_attempt(
((self.item_id, prompt_id, g, {}, ["save"], {}), self.item_id)
)
values = cached_string_outputs(self.executor, node_id, g[node_id]["class_type"])
found = self.runs.recorded_fields(prompt_id, node_id, values)
self.runs.end_attempt(self.item_id)
return found
def test_each_node_kind_keeps_its_runtime_fields(self):
for kind in (
"PromptManager",
"PromptManagerFocused",
"PromptManagerSplit",
"PromptManagerText",
):
with self.subTest(kind=kind):
g = graph(kind)
self.first(g, text=f" {LLM} ")
self.assertEqual(
self.lookup(graph(kind, seed=2)), [((f" {LLM} ", "", ""),)]
)
def test_pair_keeps_positive_and_negative_fields(self):
g = graph("PromptManagerPair", negative_text="typed negative")
self.first(g, negative="typed negative")
self.assertEqual(
self.lookup(
graph("PromptManagerPair", negative_text="typed negative", seed=2)
),
[((LLM, "", ""), ("typed negative", "", ""))],
)
def test_a_disabled_node_keeps_no_fields(self):
g = graph()
g["pm"]["inputs"]["save_to_library"] = False
self.first(g)
g2 = graph(seed=2)
g2["pm"]["inputs"]["save_to_library"] = False
self.assertIsNone(self.lookup(g2))
class TestCacheReader(unittest.TestCase):
def setUp(self):
patcher = mock.patch.dict(sys.modules, host_modules())
+22
View File
@@ -59,6 +59,28 @@ class TestGalleryConfig(unittest.TestCase):
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
self.assertEqual(GalleryConfig.PROCESSING_DELAY, 5.0)
def test_save_foreign_runs_defaults_on_and_only_booleans_apply(self):
"""#247: gallery.monitoring.save_foreign_runs, default true."""
self.assertIs(
GalleryConfig.get_config()["monitoring"]["save_foreign_runs"], True
)
GalleryConfig.update_config({"monitoring": {"save_foreign_runs": False}})
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
for junk in ("true", 1, None):
with self.subTest(value=junk):
with self.assertLogs("prompt_manager.config", "WARNING") as logs:
GalleryConfig.update_config(
{"monitoring": {"save_foreign_runs": junk}}
)
self.assertIn(
"Ignoring gallery monitoring save_foreign_runs: expected true "
"or false",
"\n".join(logs.output),
)
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
GalleryConfig.update_config({"monitoring": {"save_foreign_runs": True}})
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, True)
def test_update_tracking(self):
GalleryConfig.update_config({"tracking": {"prompt_timeout": 300}})
self.assertEqual(GalleryConfig.PROMPT_TIMEOUT, 300)
+143 -3
View File
@@ -100,14 +100,19 @@ class HostTests(unittest.TestCase):
self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache())
def begin(
self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post "
self,
kind="PromptManager",
raw=" raw ",
prepend=" pre ",
append=" post ",
negative="negative",
):
g = {
"p": {
"class_type": kind,
"inputs": {
"text": raw,
"negative_text": "negative",
"negative_text": negative,
"prepend_text": prepend,
"append_text": append,
},
@@ -125,7 +130,7 @@ class HostTests(unittest.TestCase):
},
},
}
if type(raw) is list or type(prepend) is list:
if any(type(value) is list for value in (raw, prepend, negative)):
g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}}
g["stateful"] = {
"class_type": "PromptManagerText",
@@ -445,3 +450,138 @@ class HostTests(unittest.TestCase):
ref = install_and_remove()
gc.collect()
self.assertIsNone(ref())
# -- #403: a cached runtime text takes its fields from the reused execution
class Receipts:
"""A fake ``CachedRuns.recorded_fields``: fixed fields, calls recorded."""
def __init__(self, fields):
self.fields, self.calls = fields, []
def recorded_fields(self, prompt_id, node_id, values):
self.calls.append((prompt_id, node_id, values))
return self.fields
def observe_with(self, receipts, outputs, **begin):
self.begin(**begin)
self.executor.caches.outputs.entries["p"] = Entry(None, outputs)
self.observe("execution_start")
host.observe_cached_context(
self.executor,
"execution_cached",
{"prompt_id": JOB, "nodes": ["p"]},
receipts=receipts,
)
return self.seal()
def test_cached_runtime_raw_comes_from_the_matching_receipt(self):
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertEqual(receipts.calls, [(JOB, "p", (("cached",),))])
self.assertEqual(result.raw.value, "LLM raw")
self.assertEqual(
(result.raw.provenance, result.raw.source), ("inferred", "graph_semantics")
)
self.assertEqual(result.effective.value, "cached")
self.assertEqual(result.effective.source, "host_cache_output")
# Fields the graph already knows are never replaced
self.assertEqual(result.prepend.value, " pre ")
self.assertEqual(result.prepend.source, "graph_literal")
self.assertEqual(result.append.value, " post ")
def test_cached_runtime_prepend_comes_from_the_matching_receipt(self):
receipts = self.Receipts([((" raw ", "LLM pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], prepend=["dynamic", 0]
).positive
self.assertEqual(result.prepend.value, "LLM pre")
self.assertEqual(result.prepend.source, "graph_semantics")
self.assertEqual(result.raw.value, " raw ")
self.assertEqual(result.raw.source, "graph_literal")
def test_a_receipt_disagreeing_with_a_known_field_fills_nothing(self):
receipts = self.Receipts([(("LLM raw", "other pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.prepend.value, " pre ")
def test_pair_runtime_negative_takes_the_negative_fields(self):
receipts = self.Receipts(
[((" raw ", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["LLM negative"]],
kind="PromptManagerPair",
negative=["dynamic", 0],
)
self.assertEqual(result.negative.raw.value, "LLM negative")
self.assertEqual(result.negative.raw.source, "graph_semantics")
self.assertEqual(result.positive.raw.value, " raw ")
self.assertEqual(result.positive.raw.source, "graph_literal")
def test_known_typed_fields_are_never_replaced_by_a_receipt(self):
receipts = self.Receipts([(("other", "other", "other"),)])
result = self.observe_with(receipts, [object(), ["cached"]]).positive
self.assertEqual(
(result.raw.value, result.prepend.value, result.append.value),
(" raw ", " pre ", " post "),
)
self.assertEqual(result.raw.source, "graph_literal")
def test_no_matching_receipt_keeps_the_cached_raw_unknown(self):
for fields in (None, [None], [], "bad", [(("a", "b"),)], [((1, "", ""),)]):
with self.subTest(fields=fields):
result = self.observe_with(
self.Receipts(fields), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_a_failing_receipt_lookup_keeps_the_capture(self):
class Broken:
def recorded_fields(self, *args):
raise RuntimeError("boom")
result = self.observe_with(
Broken(), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_pair_slots_take_their_own_receipt_fields(self):
receipts = self.Receipts(
[(("LLM positive", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["cached negative"]],
kind="PromptManagerPair",
raw=["dynamic", 0],
)
self.assertEqual(
receipts.calls[0], (JOB, "p", (("cached positive",), ("cached negative",)))
)
self.assertEqual(result.positive.raw.value, "LLM positive")
self.assertEqual(result.positive.effective.value, "cached positive")
# The negative editor is typed: the graph's value stays
self.assertEqual(result.negative.raw.value, "negative")
self.assertEqual(result.negative.raw.source, "graph_literal")
def test_default_receipts_are_the_process_cached_runs(self):
from utils import cached_runs
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
with patch.object(cached_runs, "CACHED_RUNS", receipts):
self.begin(raw=["dynamic", 0])
self.executor.caches.outputs.entries["p"] = Entry(
None, [object(), ["cached"]]
)
self.observe("execution_start")
self.observe("execution_cached", nodes=["p"])
self.assertEqual(self.seal().positive.raw.value, "LLM raw")
+150 -1
View File
@@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB):
return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})])
class HistoryHooksTests(unittest.TestCase):
class HooksCase(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
@@ -122,6 +122,8 @@ class HistoryHooksTests(unittest.TestCase):
def drain(self, registration):
drain_writer(registration.writer, clock=time.monotonic)
class HistoryHooksTests(HooksCase):
def test_package_history_initializes_when_monitor_and_usage_setup_fail(self):
try:
from tests.test_init_package import (
@@ -767,3 +769,150 @@ class HistoryHooksTests(unittest.TestCase):
self.assertEqual(
calls, ["execution_start", "execution_cached", "execution_cached"]
)
class CachedRuntimeHistoryTests(HooksCase):
"""A cached runtime-linked prompt is a tracked run, like an executed one (#403).
The real history hook and writer, a real ``CachedRuns`` and the real host
adapter over fakes of ComfyUI's executor, cache and executing context.
"""
TEXT = "a lighthouse the LLM wrote"
GRAPH = {
"str": {"class_type": "PrimitiveString", "inputs": {"value": "seed"}},
"llm": {"class_type": "PreviewAny", "inputs": {"source": ["str", 0]}},
"p": {"class_type": "PromptManager", "inputs": {"text": ["llm", 0]}},
"s": {"class_type": "KSampler", "inputs": {"positive": ["p", 0]}},
}
def setUp(self):
super().setUp()
from utils import cached_runs
from utils.cached_runs import CachedRuns
self.context = None
self.runs = CachedRuns(context=lambda: self.context or (None, None))
self.cache_entries = {}
entries = self.cache_entries
execution = types.ModuleType("execution")
caching = types.ModuleType("comfy_execution.caching")
package = types.ModuleType("comfy_execution")
utils_module = types.ModuleType("comfy_execution.utils")
entry_type = namedtuple("CacheEntry", "ui outputs")
class PromptExecutor:
def add_message(self, event, data, broadcast):
return None
class HierarchicalCache:
def get_local(self, node_id):
return entries.get(node_id)
execution.PromptExecutor, execution.CacheEntry = PromptExecutor, entry_type
caching.HierarchicalCache = HierarchicalCache
package.caching, package.utils = caching, utils_module
utils_module.get_executing_context = lambda: (
SimpleNamespace(prompt_id=self.context[0], node_id="p", list_index=0)
if self.context
else None
)
self.entry_type = entry_type
modules = patch.dict(
sys.modules,
{
"execution": execution,
"comfy_execution": package,
"comfy_execution.caching": caching,
"comfy_execution.utils": utils_module,
},
)
modules.start()
self.addCleanup(modules.stop)
swap = patch.object(cached_runs, "CACHED_RUNS", self.runs)
swap.start()
self.addCleanup(swap.stop)
self.executor = PromptExecutor()
self.executor.server = self.server
self.executor.caches = SimpleNamespace(outputs=HierarchicalCache())
self.prompt_id = self.db.save_prompt(text=self.TEXT)
self.item_id = 0
def job(self, cached):
"""One job; the node executes unless ``cached`` (ComfyUI's cache serves it)."""
from uuid import uuid4
from utils.cached_runs import stored_facts
from utils.execution_context_capture import record_runtime_context
self.item_id += 1
job = str(uuid4())
result = ((0, job, self.GRAPH, {}, ["s"]), self.item_id)
self.queue.results.append(result)
self.queue.get()
self.runs.begin_attempt(result)
self.context = (job, 0)
self.executor.add_message("execution_start", {"prompt_id": job}, False)
if cached:
self.executor.add_message(
"execution_cached", {"prompt_id": job, "nodes": ["p"]}, False
)
else:
# What PromptManager.encode_prompt records for this text
record_runtime_context("PromptManager", self.TEXT, "", "", self.TEXT)
self.runs.record_item(
"p", (self.TEXT,), stored_facts(self.TEXT), ((self.TEXT, "", ""),)
)
self.cache_entries["p"] = self.entry_type(None, [object(), [self.TEXT]])
self.context = None
self.queue.task_done(self.item_id, {}, status(job=job))
self.runs.end_attempt(self.item_id)
def tracked_runs(self):
with self.db.recorded_history_snapshot():
return self.db.list_history_window(self.prompt_id)["recorded_run_count"]
def test_three_jobs_of_runtime_text_are_three_tracked_runs(self):
registration = self.register()
for cached in (False, True, True):
self.job(cached)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 3)
rows = self.rows()
for row in rows:
contexts = self.db.get_execution_contexts(row["run_id"])["contexts"]
self.assertEqual(contexts[0]["positive"]["raw"], self.TEXT)
self.assertEqual(contexts[0]["positive_prompt_id"], self.prompt_id)
cached = self.db.get_execution_contexts(rows[1]["run_id"])["contexts"][0]
self.assertEqual(
cached["evidence"]["positive"]["raw"],
{"provenance": "inferred", "source": "graph_semantics", "reason": None},
)
self.assertEqual(
cached["evidence"]["positive"]["effective"]["source"], "host_cache_output"
)
# The stored history passes the whole-database check older builds run too
from database.execution_history_schema import history_schema_error
self.assertIsNone(history_schema_error(self.db.model.get_connection()))
# An image of a cached job can now be attributed through its run (#342)
from database.run_attribution import named_prompt
self.assertEqual(
[named_prompt(self.db, row["run_id"]) for row in rows],
[self.prompt_id] * 3,
)
def test_without_a_receipt_a_cached_job_stays_untracked(self):
registration = self.register()
self.job(False)
self.runs._receipts.clear() # as after a restart with a provider entry
self.job(True)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 1)
cached = self.db.get_execution_contexts(self.rows()[1]["run_id"])["contexts"]
self.assertIsNone(cached[0]["positive"]["raw"])
self.assertIsNone(cached[0]["positive_prompt_id"])
from database.run_attribution import named_prompt
self.assertIsNone(named_prompt(self.db, self.rows()[1]["run_id"]))
+330
View File
@@ -0,0 +1,330 @@
"""#247 PR 2 x #90: the monitor's foreign path never stores switched-off text.
A file whose run's prompt has Save to library off is the opted-out path's,
not the foreign path's; a foreign graph whose ladder decision is
``not_saved`` (a switched-off node the #90 walk does not reach) stores
nothing at settle, at the re-check after the seal, or from recorded history,
and leaves no wait behind.
"""
import os
import unittest
from tests import saver_anchor_graphs as shapes
from tests import test_run_attribution as attribution
from tests import test_save_to_library as save_tests
from tests.test_pair_scan import sampled_graph
from utils.foreign_runs import ForeignRecheck
from utils.hashing import generate_prompt_hash
from py.config import GalleryConfig # after tests: it stubs the host
LOGGER = "prompt_manager.image_monitor"
OFF_TEXT = "SECRET OFF TEXT"
SKIPPED = "Save to library is off for this run's prompt, skipping image"
# The class dict, not the class: a TestCase bound here would run again
Fixture = vars(attribution.TestMonitorForeignRuns)
def off_behind(middle="MyNode", key="value"):
"""A switched-off PromptManagerText reaching a core CLIPTextEncode through
``middle``'s ``key`` input: the #90 walk does not follow ``key``."""
graph = save_tests.run_graph()
graph["1"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
graph["7"] = {"class_type": middle, "inputs": {key: ["1", 0]}}
graph["8"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
}
graph["3"]["inputs"]["positive"] = ["8", 0]
return graph
def off_text_unwired():
"""Only history can decide; the legacy answer is a dangling switched-off
node's own text (the ladder says ``not_saved`` once it may ask history)."""
graph = sampled_graph("unused", "blurry")
graph["1"]["inputs"]["text"] = ["30", 0]
graph["30"] = {"class_type": "VendorPromptMaker", "inputs": {"prompt": OFF_TEXT}}
graph["40"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
return graph
class TestForeignPathKeepsSaveToLibrary(attribution.RecordedRuns, unittest.TestCase):
setUp = Fixture["setUp"]
close_databases = Fixture["close_databases"]
wait_for = Fixture["wait_for"]
image = Fixture["image"]
linked_text = Fixture["linked_text"]
key = Fixture["key"]
prompt_count = Fixture["prompt_count"]
settled = Fixture["settled"]
def reference(self, path):
return {
"folder_type": "output",
"subfolder": "dataset",
"filename": os.path.basename(path),
}
def seal(self, path, positives=(), nodes=("9",)):
"""Record one run reporting ``path`` (a new run id for every call)."""
self.runs = getattr(self, "runs", -1) + 1
self.record_run(
self.db,
self.output,
[("dataset", os.path.basename(path))],
list(positives),
item=self.runs,
nodes=list(nodes),
)
def recheck(self, path):
"""The re-check the seal schedules, run through the monitor's entry."""
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path, ForeignRecheck(self.reference(path)))
return "\n".join(logs.output)
def assert_nothing_stored(self, path):
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.prompt_count(), 0)
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(OFF_TEXT)))
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertEqual(list(self.handler._foreign_due), [])
def test_q1_the_only_prompt_manager_node_off_is_opted_out_not_foreign(self):
graph = save_tests.run_graph(save=False)
# The switched-off positive is the only PromptManager node
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "blurry"}}
sampler_less = {
"1": {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
}
for name, graph in (("sampled", graph), ("sampler-less", sampler_less)):
with self.subTest(name=name):
self.metadata = {"prompt": graph}
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(state, "opted_out")
path = self.image(f"IMG_GEN_{name}_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.seal(path)
self.assertTrue(self.settled())
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
def test_q2_an_off_text_node_behind_a_pass_through_stores_nothing(self):
for middle, key in (("MyNode", "value"), ("easy showAnything", "anything")):
with self.subTest(middle=middle):
self.metadata = {"prompt": off_behind(middle, key)}
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(state, "foreign")
path = self.image(f"IMG_GEN_{key}_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.assert_nothing_stored(path)
self.seal(path)
self.assertTrue(self.settled())
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
def test_q2_a_wired_switch_behind_a_pass_through_stores_nothing(self):
graph = off_behind()
graph["1"]["inputs"]["save_to_library"] = ["20", 0]
graph["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
self.metadata = {"prompt": graph}
path = self.image("IMG_GEN_wired_00001_.png")
wired = "Save to library is wired from a node the image cannot show"
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(wired, "\n".join(logs.output))
self.seal(path)
self.assertTrue(self.settled())
self.assertIn(wired, self.recheck(path))
self.assert_nothing_stored(path)
def test_q3_an_undecided_not_saved_waits_for_the_record_then_skips(self):
"""No record yet: a not_saved of the undecided step is not final (a
recorded saver may resolve another branch), so the file waits for its
run; the decision with the record stores nothing and logs the skip."""
wired = off_text_unwired()
wired["40"]["inputs"]["save_to_library"] = ["20", 0]
wired["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
cases = (
("off", off_text_unwired(), SKIPPED),
("wired", wired, "Save to library is wired from a node"),
)
for name, graph, line in cases:
with self.subTest(name=name):
self.metadata = {"prompt": graph}
path = self.image(f"vendor_{name}_00001_.png")
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
with self.assertLogs(LOGGER, "INFO") as logs:
self.seal(path)
self.assertTrue(
self.wait_for(lambda: not self.handler.awaiting_foreign())
)
self.assertTrue(self.settled())
self.assertIn(line, "\n".join(logs.output))
self.assert_nothing_stored(path)
def test_q3_an_early_not_saved_never_waits_even_with_a_saver_branch(self):
"""Review: a switched-off node feeding one branch's positive is the
ladder's early, final not_saved (PR 1's #90 rule covers every
sampler's positive), so the file never waits and nothing is stored,
not even the plain branch the recorded saver names."""
graph = shapes.shared_prefix()
graph["20"]["inputs"]["text"] = ["50", 0]
graph["50"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
self.metadata = {"prompt": graph}
path = self.image("IMG_GEN_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.assert_nothing_stored(path)
self.seal(path, nodes=("13",))
self.assertTrue(self.settled())
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
self.assertIsNone(
self.db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[0]))
)
def test_q3_an_off_node_in_the_recorded_saver_branch_is_never_foreign(self):
"""PR 3's parity case: a switched-off node feeds the very branch the
recorded saver names (saver "23" is branch 2). A literal switch is the
opted-out path's; a wired one is the node's own run's (#90), never the
foreign path's. Nothing is stored at settle or after the run, and no
file waits for a foreign re-check."""
cases = (
("off", False, "opted_out", SKIPPED),
("wired", ["52", 0], "unknown", "No certain prompt for"),
)
for name, save, state, line in cases:
with self.subTest(name=name):
graph = shapes.shared_prefix()
graph["50"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": save},
}
graph["20"]["inputs"]["text"] = ["50", 0]
if isinstance(save, list):
graph["52"] = {"class_type": "SomeBoolNode", "inputs": {}}
self.metadata = {"prompt": graph}
found, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(found, state)
path = self.image(f"IMG_GEN_{name}_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(line, "\n".join(logs.output))
self.assert_nothing_stored(path)
self.seal(path, positives=[OFF_TEXT], nodes=("23",))
self.assertTrue(self.settled())
self.handler.process_new_image(path)
self.assert_nothing_stored(path)
self.assertIsNone(
self.db.get_prompt_by_hash(
generate_prompt_hash(shapes.BRANCH_TEXTS[1])
)
)
def test_q4_an_off_node_on_the_negative_only_still_links_the_positive(self):
positive = "a lighthouse on a cliff at dusk"
plain = sampled_graph(positive, "unused")
plain["2"] = {
"class_type": "PromptManager",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
behind = sampled_graph(positive, "unused")
behind["2"]["inputs"]["text"] = ["7", 0]
behind["7"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
for name, graph in (("direct", plain), ("behind an encoder", behind)):
with self.subTest(name=name):
self.metadata = {"prompt": graph}
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(state, "foreign")
path = self.image(f"plain_{len(name)}_00001_.png")
self.handler.process_new_image(path)
self.assertEqual(self.linked_text(path), positive)
self.assertIsNone(
self.db.get_prompt_by_hash(generate_prompt_hash(OFF_TEXT))
)
pairs = self.db.list_prompt_pairs()["pairs"]
self.assertEqual(pairs, [])
self.assertEqual(self.handler.awaiting_foreign(), [])
def test_q5_recorded_history_never_brings_back_switched_off_text(self):
"""Even a recorded run whose contexts hold the off text (an older
history row) cannot name it, at settle or at the re-check."""
cases = {"pass-through": off_behind(), "dangling": off_text_unwired()}
for name, graph in cases.items():
with self.subTest(name=name):
self.metadata = {"prompt": graph}
path = self.image(f"IMG_GEN_{len(name)}_00001_.png")
self.seal(path, positives=[OFF_TEXT], nodes=("9",))
self.handler.process_new_image(path)
self.assert_nothing_stored(path)
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
def test_q6_with_foreign_saving_off_save_to_library_is_unchanged(self):
GalleryConfig.SAVE_FOREIGN_RUNS = False
opted_out = save_tests.run_graph(save=False)
self.metadata = {"prompt": opted_out}
skipped = self.image("IMG_GEN_off_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(skipped)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.metadata = {"prompt": off_behind()}
foreign = self.image("IMG_GEN_foreign_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(foreign)
self.assertIn("No PromptManager prompt in workflow", "\n".join(logs.output))
for path in (skipped, foreign):
self.assert_nothing_stored(path)
# A switched-on PromptManager positive still links by its metadata
stored = self.db.save_prompt(
text=save_tests.POSITIVE,
prompt_hash=generate_prompt_hash(save_tests.POSITIVE),
)
self.metadata = {"prompt": save_tests.run_graph(save=True)}
on = self.image("IMG_GEN_on_00001_.png")
self.handler.process_new_image(on)
self.assertEqual(self.linked_text(on), save_tests.POSITIVE)
self.assertEqual(
self.db.get_prompt_by_hash(generate_prompt_hash(save_tests.POSITIVE))["id"],
stored,
)
def test_the_shared_prefix_dataset_still_links_after_the_seal(self):
"""Regression guard (passes before the fix): the wait still works."""
self.metadata = {"prompt": shapes.shared_prefix()}
path = self.image("IMG_GEN_00001_.png")
self.handler.process_new_image(path)
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
self.seal(path, nodes=("13",))
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
if __name__ == "__main__":
unittest.main()
+24 -1
View File
@@ -172,17 +172,40 @@ class TestForeignWorkflows(LinkingTestCase):
self.tracker.set_current_prompt(text, {"prompt_id": prompt_id})
return prompt_id
def _linked_text(self):
row = (
self.db.model.get_connection()
.execute(
"SELECT p.text FROM generated_images g "
"JOIN prompts p ON p.id = g.prompt_id WHERE g.image_path = ?",
(self.image,),
)
.fetchone()
)
return row[0] if row else None
def test_foreign_image_leaves_the_batch_queue_aligned(self):
"""#247: it links to its own prompt and never takes a queue entry."""
first = self._queue_batch_item("item one")
self.assertIsNone(self._process({"prompt": foreign_graph()}))
self.assertEqual(self.linked, [])
self.assertEqual(self.tracker.pop_next_prompt()["id"], first)
self.assertIsNone(self.tracker.pop_next_prompt())
self.assertEqual(self._linked_text(), "a cat")
prompt = self.db.get_prompt_by_hash(generate_prompt_hash("a cat"))
self.assertEqual(prompt["run_count"] or 0, 0)
def test_foreign_image_is_not_linked_by_the_later_fallbacks_either(self):
def test_a_foreign_image_is_linked_only_by_the_ladder(self):
self._queue_batch_item("item one") # live tracker and recent prompt exist
untraceable = foreign_graph()
untraceable["9"]["inputs"]["text"] = ["30", 0]
untraceable["30"] = {"class_type": "VendorPromptMaker", "inputs": {}}
self.assertIsNone(self._process({"prompt": untraceable}))
self.assertEqual(self.linked, [])
self.assertIsNone(self._linked_text())
self.assertIsNone(self._process({"prompt": foreign_graph()}))
self.assertEqual(self.linked, [])
self.assertEqual(self._linked_text(), "a cat")
def test_negative_only_workflow_is_foreign(self):
self._save("blurry")
+5
View File
@@ -597,6 +597,11 @@ class TestHandlerScheduling(unittest.TestCase):
self.handler._schedule_recheck(path)
self.assertIsNone(self.handler._worker)
self.assertEqual(list(self.handler._history_due), [])
# #247: a foreign file's sealed re-check is dropped the same way
self.handler._await_foreign(path)
self.assertEqual(self.handler.awaiting_foreign(), [])
self.handler._schedule_recheck(path, {"filename": "late.png"})
self.assertEqual(list(self.handler._foreign_due), [])
self.assertEqual(self._live_workers() - before, set())
def test_a_sealed_run_starts_a_worker_on_an_open_handler(self):
+4 -1
View File
@@ -99,7 +99,10 @@ class TestPairUsageTracking(unittest.TestCase):
unique_id="1",
)
node.cached_runs.record_item.assert_called_once_with(
"1", ("cat", ""), Facts(usage_tracking.generate_prompt_hash("cat"), None)
"1",
("cat", ""),
Facts(usage_tracking.generate_prompt_hash("cat"), None),
(("cat", "", ""), ("", "", "")),
)
def test_validation_rejection_never_counts_or_saves(self):
+8 -2
View File
@@ -308,7 +308,11 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
"typed text", prepend_text="pre", prompt=self.GRAPH, unique_id="1"
)
node.cached_runs.record_item.assert_called_once_with(
"1", ("pre typed text",), Facts(generate_prompt_hash("typed text"))
"1",
("pre typed text",),
Facts(generate_prompt_hash("typed text")),
# #403: the texts the history capture got, for a cached job's history
(("typed text", "pre", ""),),
)
# Hook-visible text: the node counts nothing itself
row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text"))
@@ -319,7 +323,9 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
node = self._node()
node.process_text(" ", prompt=self.GRAPH, unique_id="1")
node.cached_runs.record_item.assert_called_once_with("1", (" ",), Facts(None))
node.cached_runs.record_item.assert_called_once_with(
"1", (" ",), Facts(None), ((" ", "", ""),)
)
if __name__ == "__main__":
+465
View File
@@ -33,6 +33,7 @@ from utils.execution_outputs import (
snapshot_output_roots,
)
from utils.hashing import generate_prompt_hash
from py.config import GalleryConfig
from utils.image_monitor import ImageGenerationHandler
STYLE = "windows" if os.name == "nt" else "posix"
@@ -771,6 +772,470 @@ class TestMonitorHistoryRecheck(RecordedRuns, unittest.TestCase):
run_attribution.notify_outputs_sealed("run", []) # no dead callback raises
class TestMonitorForeignRuns(RecordedRuns, unittest.TestCase):
"""#247: outputs of workflows without a PromptManager node, in the monitor."""
def setUp(self):
history_tests.HistoryDatabaseTests.setUp(self)
self.db = history_tests.HistoryDatabaseTests.database(self)
self.output = str(Path(self.temp.name) / "output")
os.makedirs(os.path.join(self.output, "dataset"))
self.tracker = make_tracker(self.db)
self.handler = ImageGenerationHandler(self.db, self.tracker)
self.handler.processing_delay = 0
self.handler.settle_interval = 0.01
self.addCleanup(close_handler, self, self.handler)
self.metadata = {"prompt": shapes.shared_prefix()}
saved = GalleryConfig.SAVE_FOREIGN_RUNS
self.addCleanup(setattr, GalleryConfig, "SAVE_FOREIGN_RUNS", saved)
GalleryConfig.SAVE_FOREIGN_RUNS = True
patches = (
mock.patch(
"utils.image_monitor.host_output_directory", return_value=self.output
),
mock.patch(
"database.run_attribution.host_output_directory",
return_value=self.output,
),
mock.patch.object(
self.handler.metadata_extractor,
"extract_metadata",
side_effect=lambda path: self.metadata,
),
)
for patch in patches:
patch.start()
self.addCleanup(patch.stop)
close_databases = history_tests.HistoryDatabaseTests.close_databases
wait_for = TestMonitorHistoryRecheck.wait_for
def image(self, name, subfolder="dataset"):
path = os.path.join(self.output, subfolder, name)
Path(path).write_bytes(b"png")
return path
def linked_text(self, path):
row = (
self.db.model.get_connection()
.execute(
"SELECT p.text FROM generated_images g "
"JOIN prompts p ON p.id = g.prompt_id WHERE g.image_path = ?",
(str(path),),
)
.fetchone()
)
return row[0] if row else None
def key(self, path):
return self.handler._awaiting_key(path)
def prompt_count(self):
connection = self.db.model.get_connection()
return connection.execute("SELECT COUNT(*) FROM prompts").fetchone()[0]
def settled(self):
return self.wait_for(lambda: self.handler.pending_count() == 0)
def test_each_dataset_file_links_to_its_branch_once_the_run_ends(self):
"""CUJ 1: unlinked while the run is open, its own branch after the seal."""
paths = [self.image(f"IMG_GEN_0000{n}_.png") for n in (1, 2)]
for path in paths:
self.handler.process_new_image(path)
self.assertEqual([self.linked_text(path) for path in paths], [None, None])
self.assertEqual(
self.handler.awaiting_foreign(), [self.key(path) for path in paths]
)
self.assertEqual(self.handler.awaiting_history(), [])
self.record_run(
self.db,
self.output,
[("dataset", os.path.basename(path)) for path in paths],
[],
nodes=["13", "23"],
)
self.assertTrue(self.wait_for(lambda: all(map(self.linked_text, paths))))
self.assertTrue(self.settled())
self.assertEqual(
[self.linked_text(path) for path in paths], list(shapes.BRANCH_TEXTS[:2])
)
self.assertEqual(self.handler.awaiting_foreign(), [])
prompt = self.db.get_prompt_by_hash(
generate_prompt_hash(shapes.BRANCH_TEXTS[0])
)
self.assertEqual(prompt["category"], "scanned")
self.assertIn("Saved from a workflow run: IMG_GEN_00001_.png", prompt["notes"])
def test_a_run_sealed_before_the_file_settles_links_it_at_once(self):
path = self.image("IMG_GEN_00003_.png")
self.record_run(
self.db, self.output, [("dataset", "IMG_GEN_00003_.png")], [], nodes=["33"]
)
self.handler.process_new_image(path)
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[2])
self.assertEqual(self.handler.awaiting_foreign(), [])
def test_a_name_two_runs_reported_stays_unlinked(self):
"""CUJ 3: the file was overwritten; no run proves its saver."""
path = self.image("IMG_GEN_00004_.png")
reported = [("dataset", "IMG_GEN_00004_.png")]
self.record_run(self.db, self.output, reported, [], nodes=["13"])
self.record_run(self.db, self.output, reported, [], item=1, nodes=["23"])
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
reference = {"folder_type": "output", "subfolder": "dataset"}
reference["filename"] = "IMG_GEN_00004_.png"
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler._recheck_foreign(path, reference)
self.assertIn(
"No certain prompt for IMG_GEN_00004_.png after its run ended; "
"left unlinked",
"\n".join(logs.output),
)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.prompt_count(), 0)
def test_the_setting_off_stores_nothing_at_settle_or_after_the_run(self):
"""CUJ 4: read for every file, so it applies without a restart."""
GalleryConfig.SAVE_FOREIGN_RUNS = False
skipped = self.image("IMG_GEN_00005_.png")
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler.process_new_image(skipped)
self.assertIn(
"No PromptManager prompt in workflow, skipping image",
"\n".join(logs.output),
)
self.assertEqual(self.handler.awaiting_foreign(), [])
GalleryConfig.SAVE_FOREIGN_RUNS = True
waiting = self.image("IMG_GEN_00006_.png")
self.handler.process_new_image(waiting)
self.assertEqual(self.handler.awaiting_foreign(), [self.key(waiting)])
GalleryConfig.SAVE_FOREIGN_RUNS = False
self.record_run(
self.db, self.output, [("dataset", "IMG_GEN_00006_.png")], [], nodes=["13"]
)
self.assertTrue(self.wait_for(lambda: not self.handler.awaiting_foreign()))
self.assertTrue(self.settled())
self.assertEqual(
[self.linked_text(p) for p in (skipped, waiting)], [None, None]
)
self.assertEqual(self.prompt_count(), 0)
def test_a_plain_foreign_workflow_links_at_settle_and_counts_no_run(self):
"""CUJ 2: the default profile decides; the tracker queue is untouched."""
text = "a lighthouse on a cliff at dusk"
self.metadata = {"prompt": sampled_graph(text, "blurry")}
queued = self.db.save_prompt(
text="queued item", prompt_hash=generate_prompt_hash("queued item")
)
self.tracker.set_current_prompt("queued item", {"prompt_id": queued})
first, second = self.image("plain_00001_.png"), self.image("plain_00002_.png")
self.handler.process_new_image(first)
self.handler.process_new_image(second)
self.assertEqual(
[self.linked_text(first), self.linked_text(second)], [text, text]
)
prompt = self.db.get_prompt_by_hash(generate_prompt_hash(text))
self.assertEqual(prompt["run_count"] or 0, 0)
self.assertIsNone(prompt.get("last_used_at"))
self.assertEqual(self.tracker.pop_next_prompt()["id"], queued)
self.assertEqual(self.handler.awaiting_foreign(), [])
def test_the_sealed_reference_spells_the_file_as_history_recorded_it(self):
"""Windows: the watcher's spelling differs in case from the run's report."""
Path(self.output, "Dataset").mkdir()
recorded = self.image("IMG_GEN_00007_.png", subfolder="Dataset")
seen = os.path.join(self.output, "dataset", "img_gen_00007_.png")
Path(seen).write_bytes(b"png")
folded = staticmethod(lambda path: os.path.realpath(path).lower())
with mock.patch.object(ImageGenerationHandler, "_awaiting_key", folded):
self.handler.process_new_image(seen)
self.assertIsNone(self.linked_text(seen))
self.record_run(
self.db,
self.output,
[("Dataset", os.path.basename(recorded))],
[],
nodes=["43"],
)
self.assertTrue(self.wait_for(lambda: self.linked_text(seen)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(seen), shapes.BRANCH_TEXTS[3])
def test_a_full_foreign_set_never_evicts_a_waiting_keyless_file(self):
from utils import image_monitor
keyless = self.image("bare_00001_.png", subfolder="")
with mock.patch.object(
self.handler.metadata_extractor, "extract_metadata", return_value=None
):
self.handler.process_new_image(keyless)
paths = [self.image(f"IMG_GEN_1000{n}_.png") for n in range(3)]
with (
mock.patch.object(image_monitor, "MAX_AWAITING_HISTORY", 2),
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
):
for path in paths:
self.handler.process_new_image(path)
self.assertEqual(
self.handler.awaiting_foreign(), [self.key(p) for p in paths[1:]]
)
self.assertEqual(self.handler.awaiting_history(), [self.key(keyless)])
self.assertIn(
"Too many files wait for their run; IMG_GEN_10000_.png left unlinked",
"\n".join(logs.output),
)
def test_an_expired_foreign_entry_is_logged_and_left_unlinked(self):
from utils import image_monitor
path = self.image("IMG_GEN_00008_.png")
self.handler.process_new_image(path)
later = time.monotonic() + image_monitor.AWAIT_HISTORY_SECONDS + 1
with (
mock.patch.object(image_monitor.time, "monotonic", return_value=later),
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
):
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertIn(
"No recorded run for IMG_GEN_00008_.png; left unlinked",
"\n".join(logs.output),
)
self.assertIsNone(self.linked_text(path))
def test_a_seal_while_the_pending_set_is_full_is_rechecked_later(self):
from utils import image_monitor
path = self.image("IMG_GEN_00009_.png")
self.handler.process_new_image(path)
blocker = self.image("blocker.png", subfolder="")
release = threading.Event()
original = self.handler.process_new_image
def slow(image_path, prompt_snapshot=None):
if image_path == blocker:
release.wait(5)
return None
return original(image_path, prompt_snapshot=prompt_snapshot)
self.handler.process_new_image = slow
with mock.patch.object(image_monitor, "MAX_PENDING_IMAGES", 1):
self.assertTrue(self.handler.schedule(blocker))
self.assertTrue(self.wait_for(lambda: self.handler.pending_count() == 1))
self.record_run(
self.db,
self.output,
[("dataset", "IMG_GEN_00009_.png")],
[],
nodes=["13"],
)
self.assertEqual(list(self.handler._foreign_due), [path])
release.set()
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
def test_shutdown_forgets_waiting_and_due_foreign_files(self):
path = self.image("IMG_GEN_00010_.png")
self.handler.process_new_image(path)
self.handler._foreign_due[path] = {"filename": "IMG_GEN_00010_.png"}
self.handler.shutdown()
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertEqual(list(self.handler._foreign_due), [])
def link_count(self, path):
connection = self.db.model.get_connection()
return connection.execute(
"SELECT COUNT(*) FROM generated_images WHERE image_path = ?", (str(path),)
).fetchone()[0]
def test_a_file_linked_to_another_prompt_never_gets_a_second_link(self):
"""One prompt per file: the pair path and the positive path both refuse."""
other = self.db.save_prompt(
"an earlier prompt", generate_prompt_hash("an earlier prompt")
)
plain = self.image("plain_00004_.png")
self.db.link_image_to_prompt(other, plain)
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler.process_new_image(plain)
self.assertIn("Foreign run: conflict", "\n".join(logs.output))
dataset = self.image("IMG_GEN_00011_.png")
self.db.link_image_to_prompt(other, dataset)
self.metadata = {"prompt": shapes.shared_prefix()}
self.record_run(
self.db, self.output, [("dataset", "IMG_GEN_00011_.png")], [], nodes=["13"]
)
self.handler.process_new_image(dataset)
for path in (plain, dataset):
self.assertEqual(self.linked_text(path), "an earlier prompt")
self.assertEqual(self.link_count(path), 1)
for text in ("a quiet harbor", shapes.BRANCH_TEXTS[0]):
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(text)))
def test_the_file_size_is_read_only_when_the_ladder_needs_it(self):
"""PR 1's lazy size: a foreign file decided without a store reads none."""
# Foreign (a core encoder only) and no API graph: never, at step 3
encoder = {"type": "CLIPTextEncode", "inputs": [], "widgets_values": ["x"]}
self.metadata = {"text_encoder_nodes": [encoder]}
path = self.image("bare_00002_.png", subfolder="")
with mock.patch("utils.foreign_runs.os.path.getsize") as size:
self.handler.process_new_image(path)
size.assert_not_called()
def untraceable(self):
"""Unresolved and only the legacy parser names a text (an unknown node)."""
graph = sampled_graph("unused", "blurry")
graph["1"]["inputs"]["text"] = ["30", 0]
graph["30"] = {
"class_type": "VendorPromptMaker",
"inputs": {"prompt": "a misty valley at dawn"},
}
return {"prompt": graph}
def test_no_record_at_settle_keeps_the_file_waiting_for_the_legacy_floor(self):
"""Review: a lookup that found no record is not the run's answer yet."""
self.metadata = self.untraceable()
path = self.image("vendor_00001_.png")
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
self.record_run(self.db, self.output, [("dataset", "vendor_00001_.png")], [])
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), "a misty valley at dawn")
def test_a_record_found_at_settle_without_a_certain_prompt_ends_the_wait(self):
"""Review: sealed before settle, still ambiguous: no seal is coming."""
path = self.image("IMG_GEN_00012_.png")
# One run reported it from two savers: a record, but no saver
reported = [("dataset", "IMG_GEN_00012_.png")] * 2
self.record_run(self.db, self.output, reported, [], nodes=["13", "23"])
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(
"No certain prompt for IMG_GEN_00012_.png; left unlinked",
"\n".join(logs.output),
)
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertIsNone(self.linked_text(path))
def test_monitoring_saved_off_stops_foreign_writes_at_settle_and_recheck(self):
"""Review: the observer runs until a restart; nothing is written."""
enabled = GalleryConfig.MONITORING_ENABLED
self.addCleanup(setattr, GalleryConfig, "MONITORING_ENABLED", enabled)
GalleryConfig.MONITORING_ENABLED = False
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
plain = self.image("plain_00005_.png")
self.handler.process_new_image(plain)
self.assertIsNone(self.linked_text(plain))
GalleryConfig.MONITORING_ENABLED = True
self.metadata = {"prompt": shapes.shared_prefix()}
waiting = self.image("IMG_GEN_00013_.png")
self.handler.process_new_image(waiting)
GalleryConfig.MONITORING_ENABLED = False
reference = {"folder_type": "output", "subfolder": "dataset"}
reference["filename"] = "IMG_GEN_00013_.png"
self.record_run(
self.db, self.output, [("dataset", reference["filename"])], [], nodes=["13"]
)
self.assertTrue(self.wait_for(lambda: not self.handler.awaiting_foreign()))
self.assertTrue(self.settled())
self.handler._recheck_foreign(waiting, reference)
self.assertIsNone(self.linked_text(waiting))
self.assertEqual(self.prompt_count(), 0)
def test_the_setting_is_read_again_just_before_the_store(self):
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
path = self.image("plain_00006_.png")
real = __import__("utils.foreign_runs", fromlist=["x"]).decide_foreign
def decide_then_switch_off(*args, **kwargs):
decision = real(*args, **kwargs)
GalleryConfig.SAVE_FOREIGN_RUNS = False
return decision
with mock.patch(
"utils.image_monitor.decide_foreign", side_effect=decide_then_switch_off
):
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.prompt_count(), 0)
def test_a_seal_between_registering_and_the_settle_lookup_links_once(self):
"""Regression guard (passes before the review fixes): one link only."""
path = self.image("IMG_GEN_00014_.png")
real = __import__("utils.foreign_runs", fromlist=["x"]).history_lookup
def sealed_first(db, image_path, reference=None):
lookup = real(db, image_path, reference)
def run_then_look():
if reference is None: # the settle lookup: the run seals now
self.record_run(
self.db,
self.output,
[("dataset", "IMG_GEN_00014_.png")],
[],
nodes=["13"],
)
return lookup()
return run_then_look
with mock.patch("utils.image_monitor.history_lookup", side_effect=sealed_first):
self.assertTrue(self.handler.schedule(path))
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
self.assertEqual(self.link_count(path), 1)
self.assertEqual(list(self.handler._foreign_due), [])
def test_a_drained_recheck_never_drops_a_newer_reference(self):
path = self.image("IMG_GEN_00015_.png")
older, newer = {"filename": "old"}, {"filename": "new"}
self.handler._foreign_due[path] = older
real = self.handler.schedule
def schedule(image_path, snapshot=None):
# The run is sealed again while the older one is scheduled
self.handler._foreign_due[image_path] = newer
return real(image_path, snapshot)
self.handler.schedule = schedule
with mock.patch.object(self.handler, "process_new_image"):
self.handler._drain_history_due()
self.assertIs(self.handler._foreign_due[path], newer)
def test_an_evicted_due_recheck_is_logged(self):
from utils import image_monitor
self.handler.schedule = lambda *args, **kwargs: False
with (
mock.patch.object(image_monitor, "MAX_AWAITING_HISTORY", 1),
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
):
for name in ("a_00001_.png", "b_00001_.png"):
self.handler._schedule_recheck(self.image(name), {"filename": name})
self.assertIn(
"Too many files wait for their run; a_00001_.png left unlinked",
"\n".join(logs.output),
)
def test_a_failed_store_is_logged_and_leaves_the_file_unlinked(self):
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
path = self.image("plain_00003_.png")
with (
mock.patch(
"utils.foreign_runs.ingest_resolved_pair",
side_effect=sqlite3.OperationalError("locked"),
),
self.assertLogs("prompt_manager.image_monitor", "ERROR"),
):
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
class TestBoundedLookup(RecordedRuns, unittest.TestCase):
"""M5: history lookups are batched, and the history schema is unchanged."""
+37
View File
@@ -323,6 +323,43 @@ class TestMonitoringSetting(SourcesTestCase):
self.assertIs(GalleryConfig.MONITORING_ENABLED, False)
class TestSaveForeignRunsSetting(SourcesTestCase):
"""#247 D-Q4: "Save prompts from other workflows", applied live."""
async def asyncSetUp(self):
await super().asyncSetUp()
saved = GalleryConfig.SAVE_FOREIGN_RUNS
self.addCleanup(setattr, GalleryConfig, "SAVE_FOREIGN_RUNS", saved)
GalleryConfig.SAVE_FOREIGN_RUNS = True
async def test_read_and_write_published_live_without_a_restart(self):
self.assertIs((await self._get())["settings"]["save_foreign_runs"], True)
self._install_monitor(FakeMonitor([self.output_dir]))
status, data = await self._post(
"/prompt_manager/settings", {"save_foreign_runs": False}
)
self.assertEqual(status, 200, data)
self.assertTrue(data["persisted"])
self.assertEqual(data["restart_required_fields"], [])
saved = self._read_config()
self.assertIs(saved["gallery"]["monitoring"]["save_foreign_runs"], False)
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
self.assertIs((await self._get())["settings"]["save_foreign_runs"], False)
async def test_only_true_or_false_is_accepted(self):
self._write_config({"custom": {"keep": True}})
before = self._config_bytes()
for value in ("false", 0, None, []):
with self.subTest(value=value):
status, data = await self._post(
"/prompt_manager/settings", {"save_foreign_runs": value}
)
self.assertEqual(status, 400, data)
self.assertIn("save_foreign_runs must be true or false", data["error"])
self.assertEqual(self._config_bytes(), before)
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, True)
class TestSourceChangeHold(SourcesTestCase):
async def test_root_save_refused_while_scan_job_runs(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
+3 -3
View File
@@ -36,14 +36,14 @@ test('execution script discovers only the new matrix and preserves all 78 origin
// + the fresh-host library location case, + #342 image attribution, + #343 linked text,
// + #332 image category routes on a generated output (image-attribution.spec.mjs, 2 projects),
// + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects),
// + #90 Save to library off (five node types)
assert.match(executions, /Total: 128 tests in 5 files/);
// + #90 Save to library off (five node types), + #247 foreign workflows (image-attribution.spec.mjs, 2 tests x 2 projects)
assert.match(executions, /Total: 132 tests in 5 files/);
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 3);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 5);
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 37);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);
+201 -28
View File
@@ -15,9 +15,14 @@ role (positive, negative, which Pair outputs are used) and what the hook
already saw are recomputed from that job's own graph, and both paths count
through the same ``own_counts`` so they cannot drift.
The receipt also keeps the raw, prepend and append text the node gave the
execution history (#403), so the history capture of a cached job can record
the raw text the graph cannot show (``recorded_fields``) and the job becomes a
tracked run of its prompt, as it is a counted one.
Fail closed: when the cached text cannot be matched to a receipt recorded in
this process, nothing is counted (the behavior before #347). In memory only and
bounded; starts no threads.
this process, nothing is counted (the behavior before #347) and the history
fields stay unknown. In memory only and bounded; starts no threads.
"""
import hashlib
@@ -39,6 +44,7 @@ try:
from .queue_observers import subscribe_dequeue, subscribe_task_done
from .run_keys import prompt_node_key, upstream_nodes
from .usage_tracking import find_pair_id_by_hashes, survive_switch
from .execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
except ImportError:
from utils.hashing import generate_prompt_hash
from utils.logging_config import get_logger
@@ -53,6 +59,7 @@ except ImportError:
from utils.queue_observers import subscribe_dequeue, subscribe_task_done
from utils.run_keys import prompt_node_key, upstream_nodes
from utils.usage_tracking import find_pair_id_by_hashes, survive_switch
from utils.execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
logger = get_logger("prompt_manager.cached_runs")
@@ -61,9 +68,15 @@ MAX_KEYS = 256 # run keys remembered (the same bound as MAX_KEYED_RUNS)
MAX_RECEIPTS = 4 # receipts per run key: a recurring upstream value (A, B, A)
MAX_ITEMS = 64 # list items per receipt (the cache reader's bound)
MAX_CACHED_NODES = 500 # the execution_cached guard the history capture uses
MAX_FIELD_TEXT_BYTES = 8 * 1024 * 1024 # history fields all receipts keep
MAX_RECEIPT_FIELD_BYTES = MAX_ATTEMPT_BYTES # one receipt: what one run's history holds
_EMPTY_HASH = generate_prompt_hash("")
Count = Tuple[Any, ...] # ("prompt", hash) or ("pair", positive, negative or None)
# (raw, prepend, append) per STRING slot, as given to record_runtime_context
Fields = Tuple[Tuple[str, str, str], ...]
# Executions with equal outputs recorded different fields: none can be named
_CONFLICT: Fields = ()
class Facts(NamedTuple):
@@ -96,6 +109,8 @@ class Seen(NamedTuple):
class _Item(NamedTuple):
outputs: Tuple[str, ...]
facts: Facts
fields: Optional[Fields] = None
field_bytes: int = 0
class _Receipt:
@@ -108,6 +123,18 @@ class _Receipt:
def outputs(self) -> Dict[int, Tuple[str, ...]]:
return {index: item.outputs for index, item in self.items.items()}
def field_bytes(self) -> int:
return sum(item.field_bytes for item in self.items.values())
def strip_fields(self) -> int:
"""Drop the history fields (counting is unchanged); the bytes freed."""
freed = 0
for index, item in list(self.items.items()):
if item.field_bytes:
freed += item.field_bytes
self.items[index] = item._replace(fields=None, field_bytes=0)
return freed
class _Attempt(NamedTuple):
item_id: int
@@ -117,6 +144,22 @@ class _Attempt(NamedTuple):
replayed: set
def _field_size(fields: Any, slots: int) -> Optional[int]:
"""UTF-8 size of well-formed fields for ``slots`` outputs, else None."""
if type(fields) is not tuple or len(fields) != slots:
return None
size = 0
for slot in fields:
if type(slot) is not tuple or len(slot) != 3:
return None
for value in slot:
try:
size += text_size(value, MAX_FIELD_BYTES) # the capture's own check
except ValueError:
return None
return size
def text_digest(value: str) -> str:
"""Digest of one returned STRING value, exactly as returned."""
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
@@ -245,11 +288,14 @@ class CachedRuns:
max_keys: int = MAX_KEYS,
max_receipts: int = MAX_RECEIPTS,
max_items: int = MAX_ITEMS,
max_field_bytes: int = MAX_FIELD_TEXT_BYTES,
context: Callable[[], Tuple[Optional[str], Optional[int]]] = _host_context,
) -> None:
self._max_keys = max_keys
self._max_receipts = max_receipts
self._max_items = max_items
self._max_field_bytes = max_field_bytes
self._field_bytes = 0 # UTF-8 bytes of the fields all receipts keep
self._context = context
self._receipts: "OrderedDict[str, List[_Receipt]]" = OrderedDict()
self._attempt: Optional[_Attempt] = None
@@ -284,7 +330,10 @@ class CachedRuns:
self._attempt = None
for run_key, receipts in self._receipts.items():
if receipts and receipts[0].item_id == item_id:
self._receipts[run_key] = _deduplicated(receipts)
before = _bytes(receipts)
deduplicated = _deduplicated(receipts)
self._field_bytes += _bytes(deduplicated) - before
self._receipts[run_key] = deduplicated
def _current(self) -> Tuple[Optional[_Attempt], Optional[int]]:
"""The attempt the executing node belongs to, and its list index."""
@@ -305,8 +354,19 @@ class CachedRuns:
with self._lock:
return str(node_id) in attempt.replayed
def record_item(self, node_id: Any, outputs: Tuple[Any, ...], facts: Facts) -> None:
"""Remember what this execution returned and was entitled to count."""
def record_item(
self,
node_id: Any,
outputs: Tuple[Any, ...],
facts: Facts,
fields: Optional[Fields] = None,
) -> None:
"""Remember what this execution returned and was entitled to count.
``fields`` are the texts the node gave the execution history, one
``(raw, prepend, append)`` per output; kept only when well formed,
each within the history's field limit and the store within its budget.
"""
try:
attempt, index = self._current()
node = attempt.nodes.get(str(node_id)) if attempt else None
@@ -317,14 +377,20 @@ class CachedRuns:
return
if any(not isinstance(value, str) for value in outputs):
return
item = _Item(tuple(text_digest(value) for value in outputs), facts)
size = _field_size(fields, len(outputs)) if fields is not None else None
item = _Item(
tuple(text_digest(value) for value in outputs),
facts,
fields if size is not None else None,
size or 0,
)
self._store(node[1], attempt.item_id, index, item)
except Exception as error:
logger.debug(f"Could not record a cached-run receipt: {error}")
def _store(self, run_key: str, item_id: int, index: int, item: _Item) -> None:
with self._lock:
receipts = self._receipts.pop(run_key, [])
receipts = self._take(run_key)
if receipts and receipts[0].item_id == item_id:
receipt = receipts[0]
else:
@@ -334,9 +400,44 @@ class CachedRuns:
receipt = _Receipt(item_id)
receipts.insert(0, receipt)
receipt.items[index] = item
self._receipts[run_key] = receipts[: self._max_receipts]
receipts = receipts[: self._max_receipts]
if item.field_bytes and (
receipt.field_bytes() > MAX_RECEIPT_FIELD_BYTES
or not self._make_room(_bytes(receipts))
):
# Counting is as before; this job's history fields stay unknown
receipt.items[index] = item._replace(fields=None, field_bytes=0)
self._put(run_key, receipts)
while len(self._receipts) > self._max_keys:
self._receipts.popitem(last=False)
_, dropped = self._receipts.popitem(last=False)
self._field_bytes -= _bytes(dropped)
def _make_room(self, needed: int) -> bool:
"""Free the fields of the least recently used receipts (under the lock).
Old receipts of workflows never run again must not hold the budget:
their counting stays, only their history fields go. False when
``needed`` does not fit even then.
"""
if needed > self._max_field_bytes:
return False
for receipts in self._receipts.values(): # least recently used first
if self._field_bytes + needed <= self._max_field_bytes:
break
for receipt in receipts:
self._field_bytes -= receipt.strip_fields()
return self._field_bytes + needed <= self._max_field_bytes
def _take(self, run_key: str) -> List[_Receipt]:
"""Remove a key's receipts and their bytes from the store (under the lock)."""
receipts = self._receipts.pop(run_key, [])
self._field_bytes -= _bytes(receipts)
return list(receipts)
def _put(self, run_key: str, receipts: List[_Receipt]) -> None:
"""Store a key's receipts as the most recently used (under the lock)."""
self._receipts[run_key] = receipts
self._field_bytes += _bytes(receipts)
# -- cached nodes -------------------------------------------------------
@@ -393,25 +494,11 @@ class CachedRuns:
def _match(self, run_key: str, values: Any) -> Optional[List[_Item]]:
"""The items of the one receipt whose outputs equal ``values``, in order."""
if not values:
return None
length = len(values[0])
if length == 0 or length > self._max_items:
return None
if any(len(slot) != length for slot in values):
return None
wanted = {
index: tuple(text_digest(slot[index]) for slot in values)
for index in range(length)
}
with self._lock:
receipts = self._receipts.get(run_key)
if not receipts:
return None
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
matches = self._matching(run_key, values)
if not matches:
return None
self._receipts.move_to_end(run_key)
length = len(values[0])
facts = {
tuple(receipt.items[index].facts for index in range(length))
for receipt in matches
@@ -420,13 +507,99 @@ class CachedRuns:
return None
return [matches[0].items[index] for index in range(length)]
def _matching(self, run_key: str, values: Any) -> List[_Receipt]:
"""Receipts of ``run_key`` whose outputs equal ``values`` (under the lock)."""
if type(values) not in (list, tuple) or not values:
return []
if any(type(slot) not in (list, tuple) for slot in values):
return []
length = len(values[0])
if length == 0 or length > self._max_items:
return []
if any(len(slot) != length for slot in values):
return []
if any(type(value) is not str for slot in values for value in slot):
return []
wanted = {
index: tuple(text_digest(slot[index]) for slot in values)
for index in range(length)
}
receipts = self._receipts.get(run_key)
if not receipts:
return []
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
if matches:
self._receipts.move_to_end(run_key)
return matches
# -- the history capture (#403) -------------------------------------------
def recorded_fields(
self, prompt_id: Any, node_id: Any, values: Any
) -> Optional[List[Fields]]:
"""The history fields of the execution a cached node reuses, per list item.
``values`` are the node's cached STRING outputs (``cached_string_outputs``).
Only for a node of the job currently executing, and only when every
receipt matching ``values`` kept the same fields; None otherwise. Never
counts and never marks the node replayed.
"""
try:
with self._lock:
attempt = self._attempt
if (
attempt is None
or type(prompt_id) is not str
or prompt_id != attempt.prompt_id
or type(node_id) is not str
or node_id not in attempt.nodes
):
return None
matches = self._matching(attempt.nodes[node_id][1], values)
if not matches:
return None
found = []
for index in range(len(values[0])):
fields = {receipt.items[index].fields for receipt in matches}
if len(fields) != 1 or None in fields or _CONFLICT in fields:
return None
found.append(fields.pop())
return found
except Exception as error:
logger.debug(f"Could not read a cached run's history fields: {error}")
return None
def _deduplicated(receipts: List[_Receipt]) -> List[_Receipt]:
"""The newest receipt replaces older ones with the same outputs."""
"""The newest receipt replaces older ones with the same outputs.
Counting needs only the outputs. When a replaced receipt recorded other
history fields for equal outputs (a raw text the outputs do not show),
the newest item's fields become a conflict, so no cached job of it is
given either text (#403). A conflict stays until the run key is dropped.
"""
if not receipts:
return receipts
outputs = receipts[0].outputs()
return [receipts[0]] + [r for r in receipts[1:] if r.outputs() != outputs]
newest, outputs = receipts[0], receipts[0].outputs()
kept = [newest]
for receipt in receipts[1:]:
if receipt.outputs() != outputs:
kept.append(receipt)
continue
for index, item in list(newest.items.items()):
older = receipt.items[index].fields
if older == _CONFLICT or (
older is not None and item.fields is not None and older != item.fields
):
newest.items[index] = item._replace(fields=_CONFLICT, field_bytes=0)
return kept
def _bytes(receipts: List[_Receipt]) -> int:
"""UTF-8 bytes of the history fields ``receipts`` keep."""
return sum(
item.field_bytes for receipt in receipts for item in receipt.items.values()
)
def _job_nodes(graph: Dict[str, Any], outputs: Any) -> Dict[str, Tuple[str, str]]:
+83 -5
View File
@@ -151,10 +151,77 @@ def cached_string_outputs(
return None
def _receipt_fields(
receipts: object, executor: object, job_id: str, source: object
) -> list | None:
"""Per list index, the ``(raw, prepend, append)`` of the execution reused (#403).
From the #347 receipt whose digests equal every STRING slot of the cache
entry; None when there is none or the answer is malformed.
"""
try:
if receipts is None:
try:
from .cached_runs import CACHED_RUNS as receipts
except ImportError:
from utils.cached_runs import CACHED_RUNS as receipts
values = cached_string_outputs(executor, source.node_id, source.node_type)
if values is None:
return None
found = receipts.recorded_fields(job_id, source.node_id, values)
if type(found) is not list or len(found) != len(values[0]):
return None
position = (
(1 if source.output_slot in (1, 3) else 0)
if source.node_type == "PromptManagerPair"
else 0
)
fields = []
for slots in found:
slot = slots[position] if type(slots) is tuple else None
if not (
type(slot) is tuple
and len(slot) == 3
and all(type(value) is str for value in slot)
):
return None
fields.append(slot)
return fields
except Exception:
return None
def _agrees(inferred: PromptSnapshot, fields: tuple) -> bool:
"""Every field the graph knows equals the receipt's; else fill nothing."""
known = (inferred.raw, inferred.prepend, inferred.append)
return all(
field.value is None or field.value == value
for field, value in zip(known, fields)
)
def _filled(inferred: TextEvidence, value: str) -> TextEvidence:
"""A field the graph cannot show, taken from the reused execution (#403)."""
if inferred.value is not None:
return inferred
return TextEvidence(value, "inferred", "graph_semantics", None)
def observe_cached_context(
executor: object, message_type: object, message_data: object
executor: object,
message_type: object,
message_data: object,
*,
receipts: object = None,
) -> None:
"""Copy bounded local strings without cache/provider execution or retention."""
"""Copy bounded local strings without cache/provider execution or retention.
Raw, prepend and append come from graph inference; a field it leaves
unknown (runtime-only text) is taken from the receipt of the execution the
cache entry is proven to come from (``CachedRuns.recorded_fields``), so the
cached job is a tracked run of its prompt (#403). ``receipts`` defaults to
the process ``CACHED_RUNS``.
"""
try:
binding = active_capture()
if binding is None or type(message_type) is not str:
@@ -198,18 +265,29 @@ def observe_cached_context(
inferred = manager.source_snapshot(cap, source)
if inferred is None:
continue
fields = None
if any(
field.value is None
for field in (inferred.raw, inferred.prepend, inferred.append)
):
fields = _receipt_fields(receipts, executor, cap.job_id, source)
for index, value in enumerate(values):
if type(value) is not str:
continue
raw, prepend, append = inferred.raw, inferred.prepend, inferred.append
if fields is not None and _agrees(inferred, fields[index]):
raw = _filled(raw, fields[index][0])
prepend = _filled(prepend, fields[index][1])
append = _filled(append, fields[index][2])
manager.observe(
cap,
source,
index,
PromptSnapshot(
inferred.raw,
raw,
TextEvidence(value, "recorded", "host_cache_output", None),
inferred.prepend,
inferred.append,
prepend,
append,
),
)
except Exception:
+142
View File
@@ -0,0 +1,142 @@
"""The monitor's path for outputs of workflows without a PromptManager node (#247).
A foreign output (no positive PromptManager node in its graph) is decided by
the ladder the output scan uses (``utils.file_prompt.decide_file_prompt``)
with the ``MONITOR`` policy: certain files are stored at once, files only
recorded history can decide wait for their run's outputs to be sealed, and
everything else stays unlinked. This module holds the parts that do not
need the monitor's waiting sets: the setting, the decision call and the
store. The waiting and the re-check stay on ``ImageGenerationHandler``.
"""
import os
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional, Tuple
from .file_prompt import (
NOT_SAVED,
NOT_SAVED_WIRED,
PAIR,
POSITIVE,
Decision,
Policy,
decide_file_prompt,
)
from .hashing import generate_prompt_hash
from .usage_tracking import survive_switch
try:
from ..database.pair_ingestion import RUN_MARKS, ingest_resolved_pair
from ..database.run_attribution import recorded_output_lookup
from ..database.scanned_prompts import store_scanned_prompt_or_raise
except ImportError:
from database.pair_ingestion import RUN_MARKS, ingest_resolved_pair
from database.run_attribution import recorded_output_lookup
from database.scanned_prompts import store_scanned_prompt_or_raise
# Decisions that name a prompt to store
CERTAIN = (PAIR, POSITIVE)
# Decisions of a run whose prompt has Save to library off, or wired from a
# node the file cannot show (#90): final, nothing is stored, nothing waits
NOT_SAVED_KINDS = (NOT_SAVED, NOT_SAVED_WIRED)
# Sources certain without a recorded run: the graph alone, or its recorded saver
ANCHORED_SOURCES = ("inspector", "recorded-run")
@dataclass(frozen=True)
class ForeignRecheck:
"""``schedule`` snapshot: the one re-check of a foreign output after its
run's outputs were sealed. ``reference`` is the sealed output reference,
the spelling history recorded (``recorded_output_lookup(reference=)``)."""
reference: Mapping[str, Any]
def save_foreign_runs() -> bool:
"""Whether a foreign output may be stored now, read for every file.
``GalleryConfig.SAVE_FOREIGN_RUNS`` and ``MONITORING_ENABLED``: a
monitoring switch saved off stops foreign writes at once, although the
running observer watches until a restart. True when the configuration
cannot be imported (the defaults).
"""
try:
from ..py.config import GalleryConfig
except ImportError:
try:
from py.config import GalleryConfig
except ImportError:
return True
return (
GalleryConfig.SAVE_FOREIGN_RUNS is not False
and GalleryConfig.MONITORING_ENABLED is not False
)
def history_lookup(
db: Any, image_path: str, reference: Optional[Mapping[str, Any]] = None
) -> Callable[[], Any]:
"""The ladder's ``lookup`` for one file: its ``RecordedOutput`` or None.
With ``reference`` the file is spelled as history recorded it. Failures
are logged by ``recorded_output_lookup`` and name nothing.
"""
lookup = recorded_output_lookup(
db, reference=dict(reference) if reference is not None else None
)
return lambda: lookup(image_path)
def decide_foreign(
image_path: str,
metadata: Any,
*,
in_output: bool,
lookup: Optional[Callable[[], Any]],
) -> Decision:
"""The ``MONITOR`` ladder for one foreign output (the size is read only
when the ladder needs it)."""
def file_size() -> Optional[int]:
try:
return os.path.getsize(image_path)
except OSError:
return None
return decide_file_prompt(
metadata,
file_name=os.path.basename(image_path),
file_size=file_size,
in_output=in_output,
lookup=lookup,
policy=Policy.MONITOR,
)
def store_foreign(
db: Any, image_path: str, decision: Decision
) -> Tuple[str, Optional[int]]:
"""Store a certain decision: ``(status, prompt id)``.
One write through ``survive_switch``, so it runs again on the moved
library when a Move library switched while it waited (#249 S6b). Created
prompts carry ``RUN_MARKS``. A file linked to another prompt is a
``conflict`` and nothing is written. Database errors are raised.
"""
def store():
if decision.kind == PAIR:
result = ingest_resolved_pair(
db, image_path, decision.canonical, decision.resolution, marks=RUN_MARKS
)
return result["status"], result.get("image_id")
return store_scanned_prompt_or_raise(
db, image_path, decision.canonical, decision.text, RUN_MARKS
)
status, linked = survive_switch(db, store)
if decision.kind != PAIR or linked is None:
return status, linked
# The pair ingester reports the image row; its prompt is the positive's.
prompt = db.get_prompt_by_hash(generate_prompt_hash(decision.text))
return status, prompt["id"] if prompt else None
+201 -41
View File
@@ -33,6 +33,17 @@ from typing import Callable, Optional, Dict, Any, List, Tuple
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from .file_prompt import HISTORY, NOT_SAVED, NOT_SAVED_WIRED
from .foreign_runs import (
ANCHORED_SOURCES,
CERTAIN,
NOT_SAVED_KINDS,
ForeignRecheck,
decide_foreign,
history_lookup,
save_foreign_runs,
store_foreign,
)
from .hashing import generate_prompt_hash
from .metadata_extractor import ComfyUIMetadataExtractor
from .parallel import note_refusal, refused_at_shutdown
@@ -153,6 +164,11 @@ class ImageGenerationHandler(FileSystemEventHandler):
# Sealed re-checks ``schedule`` refused (the path still pending, or the
# pending set full): retried each time a pending image finishes.
self._history_due: "OrderedDict[str, None]" = OrderedDict()
# The same two sets for foreign outputs (#247), apart so a large
# foreign run never evicts a waiting keyless file; the due set keeps
# each file's sealed output reference.
self._awaiting_foreign: "OrderedDict[str, Tuple[str, float]]" = OrderedDict()
self._foreign_due: "OrderedDict[str, dict]" = OrderedDict()
self._awaiting_lock = threading.Lock()
self._sealed_subscribed = False
# Set by ``close``: no new work, no new worker, ever (#302).
@@ -253,6 +269,8 @@ class ImageGenerationHandler(FileSystemEventHandler):
with self._awaiting_lock:
self._awaiting.clear()
self._history_due.clear()
self._awaiting_foreign.clear()
self._foreign_due.clear()
with self._pending_lock:
self._pending.clear()
worker, self._worker = self._worker, None
@@ -438,6 +456,9 @@ class ImageGenerationHandler(FileSystemEventHandler):
if prompt_snapshot is HISTORY_ONLY:
self._recheck_history(image_path)
return
if isinstance(prompt_snapshot, ForeignRecheck):
self._recheck_foreign(image_path, prompt_snapshot.reference)
return
self.logger.info(f"Processing image: {image_path}")
if not os.path.exists(image_path):
@@ -457,17 +478,12 @@ class ImageGenerationHandler(FileSystemEventHandler):
# negative-prompt PromptManager nodes in the workflow.
state, current_prompt = self._find_prompt_from_metadata(metadata)
if state == "opted_out":
self.logger.info(
"Save to library is off for this run's prompt, skipping image: "
f"{image_path}"
)
self._log_not_saved(image_path)
return
if state == "foreign":
# Another workflow saved this image: it belongs to no prompt of
# ours, and must not consume a batch queue entry.
self.logger.info(
f"No PromptManager prompt in workflow, skipping image: {image_path}"
)
# Another workflow saved this image: never a batch queue entry
# of ours; its own graph may name its prompt (#247).
self._ingest_foreign(image_path, metadata)
return
if current_prompt:
self.logger.info(
@@ -589,36 +605,55 @@ class ImageGenerationHandler(FileSystemEventHandler):
resolved = os.path.abspath(image_path)
return os.path.normcase(resolved)
def _prune_awaiting(self) -> None:
"""Drop expired and surplus entries (``_awaiting_lock`` held)."""
def _prune_awaiting(self, store=None) -> List[Tuple[str, bool]]:
"""Drop expired and surplus entries of ``store`` (the keyless set by
default; ``_awaiting_lock`` held). Returns the dropped foreign files
as ``(path, evicted)`` for ``_log_dropped`` after the lock."""
store = self._awaiting if store is None else store
now = time.monotonic()
for key in [k for k, (_p, due) in self._awaiting.items() if due <= now]:
del self._awaiting[key]
while len(self._awaiting) > MAX_AWAITING_HISTORY:
self._awaiting.popitem(last=False)
expired = [k for k, (_p, due) in store.items() if due <= now]
dropped = [(store.pop(key)[0], False) for key in expired]
while len(store) > MAX_AWAITING_HISTORY:
dropped.append((store.popitem(last=False)[1][0], True))
return dropped if store is self._awaiting_foreign else []
def _await_history(self, image_path: str) -> None:
def _log_dropped(self, dropped: List[Tuple[str, bool]]) -> None:
"""Log foreign files that stopped waiting (outside ``_awaiting_lock``)."""
for image_path, evicted in dropped:
name = os.path.basename(image_path)
if evicted:
self.logger.info(
f"Too many files wait for their run; {name} left unlinked"
)
else:
self.logger.info(f"No recorded run for {name}; left unlinked")
def _await_history(self, image_path: str, store=None) -> None:
"""Re-check ``image_path`` once, history only, when its run is sealed.
A closed handler re-checks nothing.
``store`` is the waiting set (``_awaiting_foreign`` for a foreign
output). A closed handler re-checks nothing.
"""
if self._closed:
return
if not self._sealed_subscribed:
subscribe_outputs_sealed(self._on_outputs_sealed)
self._sealed_subscribed = True
store = self._awaiting if store is None else store
key = self._awaiting_key(image_path)
with self._awaiting_lock:
self._awaiting.pop(key, None)
self._awaiting[key] = (
image_path,
time.monotonic() + AWAIT_HISTORY_SECONDS,
)
self._prune_awaiting()
store.pop(key, None)
store[key] = (image_path, time.monotonic() + AWAIT_HISTORY_SECONDS)
dropped = self._prune_awaiting(store)
self._log_dropped(dropped)
def _stop_awaiting(self, image_path: str) -> None:
def _await_foreign(self, image_path: str) -> None:
self._await_history(image_path, self._awaiting_foreign)
def _stop_awaiting(self, image_path: str, store=None) -> None:
store = self._awaiting if store is None else store
with self._awaiting_lock:
self._awaiting.pop(self._awaiting_key(image_path), None)
store.pop(self._awaiting_key(image_path), None)
def awaiting_history(self) -> List[str]:
"""Normalized paths still waiting for their run's recorded outputs."""
@@ -626,6 +661,14 @@ class ImageGenerationHandler(FileSystemEventHandler):
self._prune_awaiting()
return list(self._awaiting)
def awaiting_foreign(self) -> List[str]:
"""Normalized paths of foreign outputs waiting for their run (#247)."""
with self._awaiting_lock:
dropped = self._prune_awaiting(self._awaiting_foreign)
waiting = list(self._awaiting_foreign)
self._log_dropped(dropped)
return waiting
def _on_outputs_sealed(self, run_id: str, references: List[dict]) -> None:
"""A run's outputs were recorded: re-check its waiting images once.
@@ -637,6 +680,7 @@ class ImageGenerationHandler(FileSystemEventHandler):
due = []
with self._awaiting_lock:
self._prune_awaiting()
dropped = self._prune_awaiting(self._awaiting_foreign)
for reference in references:
if reference.get("folder_type") != "output":
continue
@@ -647,39 +691,53 @@ class ImageGenerationHandler(FileSystemEventHandler):
)
waiting = self._awaiting.pop(key, None)
if waiting is not None:
due.append(waiting[0])
for image_path in due:
self._schedule_recheck(image_path)
due.append((waiting[0], None))
waiting = self._awaiting_foreign.pop(key, None)
if waiting is not None:
due.append((waiting[0], dict(reference)))
self._log_dropped(dropped)
for image_path, reference in due:
self._schedule_recheck(image_path, reference)
def _schedule_recheck(self, image_path: str) -> None:
"""Schedule the history-only re-check, or keep it due for later.
@staticmethod
def _recheck_snapshot(reference):
return HISTORY_ONLY if reference is None else ForeignRecheck(reference)
def _schedule_recheck(self, image_path: str, reference=None) -> None:
"""Schedule the history-only re-check (with the sealed ``reference``:
a foreign output's), or keep it due for later.
A closed handler drops it.
"""
if self._closed:
return
if self.schedule(image_path, HISTORY_ONLY):
if self.schedule(image_path, self._recheck_snapshot(reference)):
return
with self._awaiting_lock:
# ``close`` sets the flag before ``shutdown`` clears this set, so
# a close that raced the refusal above is seen here.
if self._closed:
return
self._history_due.pop(image_path, None)
self._history_due[image_path] = None
while len(self._history_due) > MAX_AWAITING_HISTORY:
self._history_due.popitem(last=False)
due = self._history_due if reference is None else self._foreign_due
due.pop(image_path, None)
due[image_path] = reference
evicted = []
while len(due) > MAX_AWAITING_HISTORY:
evicted.append(due.popitem(last=False)[0])
if reference is not None:
self._log_dropped([(path, True) for path in evicted])
def _drain_history_due(self) -> None:
"""Schedule due re-checks now that a pending slot or path is free."""
with self._awaiting_lock:
if not self._history_due:
return
due = list(self._history_due)
for image_path in due:
if self.schedule(image_path, HISTORY_ONLY):
due = [(self._history_due, p, None) for p in self._history_due]
due += [(self._foreign_due, *item) for item in self._foreign_due.items()]
for store, image_path, reference in due:
if self.schedule(image_path, self._recheck_snapshot(reference)):
with self._awaiting_lock:
self._history_due.pop(image_path, None)
# A newer seal may have replaced it meanwhile: keep that one
if image_path in store and store[image_path] is reference:
del store[image_path]
def _recheck_history(self, image_path: str) -> None:
"""The one history-only re-check of a keyless image (#342)."""
@@ -703,6 +761,108 @@ class ImageGenerationHandler(FileSystemEventHandler):
metadata or {"file_info": self.get_basic_file_info(image_path)},
)
def _ingest_foreign(self, image_path: str, metadata) -> None:
"""A foreign output at settle: store its certain prompt, or wait for
its run's recorded outputs when only history can decide (#247)."""
name = os.path.basename(image_path)
if not save_foreign_runs():
self.logger.info(
f"No PromptManager prompt in workflow, skipping image: {image_path}"
)
return
in_output = in_output_folder(image_path, host_output_directory())
decision = decide_foreign(
image_path, metadata, in_output=in_output, lookup=None
)
if decision.kind == HISTORY:
decision = self._settle_lookup(image_path, metadata)
if decision is None:
self.logger.debug(f"{name} waits for its run to end")
return
self._stop_awaiting(image_path, self._awaiting_foreign)
if decision.kind in NOT_SAVED_KINDS:
self._log_not_saved(image_path, decision.kind)
elif decision.kind in CERTAIN:
self._store_foreign(image_path, decision)
else:
self.logger.info(f"No certain prompt for {name}; left unlinked")
def _log_not_saved(self, image_path: str, kind: str = NOT_SAVED) -> None:
"""The log line of a file whose run's prompt does not save (#90)."""
if kind == NOT_SAVED_WIRED:
reason = "is wired from a node the image cannot show"
else:
reason = "is off"
self.logger.info(
f"Save to library {reason} for this run's prompt, skipping image: "
f"{image_path}"
)
def _settle_lookup(self, image_path: str, metadata):
"""The settle-time history lookup of a file only history can decide.
Registered first, so a run sealed before this lookup is not missed.
Returns None to keep waiting: no record yet, and the answer is not
the inspector's or the recorded saver's (the legacy floor waits for
the re-check). With a record, any decision ends the wait. A
``not_saved`` here is the undecided step's (an early one never asks
history, so it never reaches this lookup): a recorded saver may still
resolve another branch, so without a record it waits too (#90).
"""
self._await_foreign(image_path)
found = []
lookup = history_lookup(self.db_manager, image_path)
def recorded():
found.append(lookup())
return found[-1]
decision = decide_foreign(image_path, metadata, in_output=True, lookup=recorded)
if any(record is not None for record in found):
return decision
if decision.kind in CERTAIN and decision.source in ANCHORED_SOURCES:
return decision
return None
def _recheck_foreign(self, image_path: str, reference) -> None:
"""The one re-check of a foreign output after its run ended (#247)."""
name = os.path.basename(image_path)
if not os.path.exists(image_path):
return
if not save_foreign_runs():
self.logger.info(f"Saving prompts from other workflows is off: {name}")
return
try:
metadata = self.metadata_extractor.extract_metadata(image_path)
except Exception as error:
self.logger.warning(f"Metadata of {name} could not be read: {error}")
return
lookup = history_lookup(self.db_manager, image_path, reference)
decision = decide_foreign(image_path, metadata, in_output=True, lookup=lookup)
if decision.kind in NOT_SAVED_KINDS:
self._log_not_saved(image_path, decision.kind)
return
if decision.kind in CERTAIN:
self._store_foreign(image_path, decision)
return
self.logger.info(
f"No certain prompt for {name} after its run ended; left unlinked"
)
def _store_foreign(self, image_path: str, decision) -> None:
name = os.path.basename(image_path)
if not save_foreign_runs(): # switched off while this file was decided
self.logger.info(f"Saving prompts from other workflows is off: {name}")
return
try:
status, prompt_id = store_foreign(self.db_manager, image_path, decision)
except Exception as error:
self.logger.error(f"Failed to store the prompt of {name}: {error}")
return
self.logger.info(
f"Foreign run: {status} prompt {prompt_id} for {name} ({decision.source})"
)
def _history_prompt(self, image_path: str) -> Optional[int]:
"""The stored prompt recorded history attributes to this file, if one."""
try:
+2 -1
View File
@@ -40,7 +40,8 @@
videoAutoplay: false,
videoMute: true,
videoLoop: true,
workspaceLayout: "grid",
// #451: Images opens in Masonry unless this browser chose another layout.
workspaceLayout: "masonry",
workspaceDensity: "comfortable",
});
+36 -15
View File
@@ -176,24 +176,32 @@ function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
/**
* The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
* shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
* a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
* The tag chips after the prompt blocks (D88, D154): the file's current image tags (`source: 'image'`), then D21's linked
* tags (`source: 'linked'`), each without case-insensitive repeats within its own source (D154: a linked tag that is also
* an image tag stays in both labelled sections). With `filterable` (a Tags filter exists, as in the Images panel) every
* image tag sets it, and a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip
* is plain.
*/
export function inspectorTagChips(record, {filterable}) {
const shown = new Set(), chips = [];
const add = (names, filter) => {
const chips = [];
const add = (names, filter, source) => {
const shown = new Set();
for (const name of names) {
const key = name.toLowerCase();
if (shown.has(key)) continue;
shown.add(key); chips.push({name, filter});
shown.add(key); chips.push({name, filter, source});
}
};
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable, 'image');
const {library} = record;
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive', 'linked');
return chips;
}
/** D154 (#449): a tag section's visible heading; linked tags name their owner as D146's category chip does. */
export function tagSectionLabel(source, library) {
if (source === 'image') return 'Image tags';
return library?.tags_scope === 'pair' ? 'Recipe tags' : 'Prompt tags';
}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
/**
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
@@ -295,14 +303,11 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
// D154 (#449): each source gets its own labelled section, so image tags never read as part of the negative prompt.
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
if (chips.length) {
const tags = el('div', undefined, 'tags');
for (const {name, filter} of chips) {
if (!filter) {tags.append(el('span', name, 'chip')); continue;}
const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
}
inner.append(tags);
for (const source of ['image', 'linked']) {
const own = chips.filter(chip => chip.source === source);
if (own.length) inner.append(tagSection(source, tagSectionLabel(source, library), own));
}
inner.append(...generationFacts(record, 'meta-grid', 'small'));
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
@@ -336,6 +341,22 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
* D154: `section.tag-section` in the prompt sections' frame, its `.role-label` heading naming the source and count.
* The region's name leaves the count out and carries the prompt blocks' context ("Current file image tags").
*/
function tagSection(source, label, chips) {
const section = el('section', undefined, `tag-section ${source}-tags`), heading = el('div', undefined, 'prompt-heading');
section.setAttribute('aria-label', promptContext ? `${promptContext} ${label.toLowerCase()}` : label);
heading.append(el('span', `${label} · ${chips.length}`, 'role-label'));
const tags = el('div', undefined, 'tags');
for (const {name: text, filter} of chips) {
if (!filter) {tags.append(el('span', text, 'chip')); continue;}
const chip = createButton(text, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(text)); tags.append(chip);
}
section.append(heading, tags);
return section;
}
/**
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
+25 -9
View File
@@ -1,23 +1,39 @@
import { noun } from './plural.mjs';
/** Identity is the source and relative path; content hashes may be shared by files. */
/** The layout and size a saved view, an embedded gallery and a page without readable preferences use. */
const FIXED_VIEW = Object.freeze({ layout: 'grid', density: 'comfortable' });
/**
* The layout and thumbnail size an Images visit starts with (#249 System S3). The last visit (the current view) keeps
* its own; a saved view starts as before, at Grid and Comfortable; any other new visit starts from System ›
* Appearance's defaults (`preferences`, preferences.mjs values, or null when unavailable).
* The defaults a new Images visit starts from: System › Appearance's (`preferences`, preferences.mjs values; Masonry
* when the layout is missing, #451), or the fixed Grid/Comfortable when they are unavailable (null).
*/
export function visitDefaults(preferences) {
if (!preferences) return { ...FIXED_VIEW };
return { layout: preferences.workspaceLayout || 'masonry', density: preferences.workspaceDensity || 'comfortable' };
}
/**
* The layout and thumbnail size an Images visit starts with (#249 System S3). A saved view starts as before, at Grid
* and Comfortable; any other new visit starts from `visitDefaults(preferences)`. The last visit (`prior`, the current
* view) keeps a value its toolbar changed; a value it kept from the defaults it started from (`prior.defaults`) follows
* defaults saved since, in this tab or another (#451). A prior without `defaults` keeps its own values.
*/
export function initialImagesView({ prior = null, saved = null, preferences = null } = {}) {
if (prior) return { layout: prior.layout || 'grid', density: prior.density || 'comfortable' };
if (saved || !preferences) return { layout: 'grid', density: 'comfortable' };
return { layout: preferences.workspaceLayout || 'grid', density: preferences.workspaceDensity || 'comfortable' };
if (!prior) return saved ? { ...FIXED_VIEW } : visitDefaults(preferences);
const own = { layout: prior.layout || FIXED_VIEW.layout, density: prior.density || FIXED_VIEW.density };
if (!prior.defaults || !preferences) return own;
const current = visitDefaults(preferences);
const follow = key => own[key] === prior.defaults[key] ? current[key] : own[key];
return { layout: follow('layout'), density: follow('density') };
}
export function imageKey(image) {
return `${image.root_index}:${image.path}`;
}
/** The workspace listing's sorts: the concept's Recently added, Name A–Z and Highest rated, plus Oldest and Z–A (D14). */
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']);
/** The workspace listing's sorts: the concept's Recently added and Name A–Z, plus Oldest and Z–A (D14; no rating, D153). */
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc']);
const isCount = value => Number.isSafeInteger(value) && value >= 0;
// The largest page a refresh asks for (the listing clamps at 500).
@@ -299,7 +315,7 @@ const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.ge
/**
* D17: the caption's right-hand date, "<Today|Yesterday|weekday> · <Month D>" of the newest result under Newest first,
* with ", YYYY" outside the current year; '' under Oldest first and the name or rating sorts.
* with ", YYYY" outside the current year; '' under Oldest first and the name sorts.
*/
export function captionDate(items, sort, now = new Date()) {
if (sort !== 'newest') return '';
+7 -4
View File
@@ -1,5 +1,5 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, listingFilter } from './images-state.mjs';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, visitDefaults, listingFilter } from './images-state.mjs';
import { createImageCard } from './image-card.mjs';
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
@@ -61,8 +61,9 @@ function selectOf(id, options, value) {
return select;
}
// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3).
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
// The concept's sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3). D153 (#447)
// drops the concept's "Highest rated".
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'],
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
const RETRY_RESCAN = 'Rescan from Import to retry.';
@@ -156,6 +157,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
// toolbar to change them, so it keeps its fixed Grid/Comfortable. The metadata and card defaults apply everywhere.
const start = initialImagesView({prior, saved, preferences:embedded ? null : preferences});
// The defaults this visit started from: a return follows defaults saved since unless the toolbar changed a value (#451).
const startDefaults = visitDefaults(embedded ? null : preferences);
let density = start.density;
let metrics = {...INITIAL_METRICS};
let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
@@ -182,7 +185,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
+10 -17
View File
@@ -9,7 +9,9 @@
*/
const MODES = new Set(['new', 'metadata', 'revision', 'negative']);
const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']);
const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating'];
// D153 (#447): the workspace edits no 1–5 rating; a stored one is left exactly as it is.
export const EDITOR_FIELDS = Object.freeze(['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
const FIELDS = EDITOR_FIELDS;
const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'};
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
@@ -106,27 +108,20 @@ export function fallbackTitle(text) {return text.split('\n')[0].trim();}
export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];}
const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length;
const stored = value => (value ?? '').trim();
function normalizeRating(value) {
if (value === null || value === '') return null;
const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value;
if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null');
return number;
}
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null});
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: ''});
function seedDraft(mode, {owner, pair}) {
if (!owner) return blankDraft();
if (mode === 'negative') return {...blankDraft(), positive_text: owner.text};
return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text,
negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '),
notes: owner.notes ?? '', rating: owner.rating};
notes: owner.notes ?? ''};
}
/** Keep fields the user changed from the previous seed; the rest follow the new seed. */
function overlay(next, draft, previous) {
return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]]));
}
function baselineOf(owner) {
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes};
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], notes: owner.notes};
}
/** Only changed fields; an untouched fallback title is not a title edit. */
@@ -136,19 +131,18 @@ function metadataChanges(draft, seed, baseline) {
if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title;
if (category !== stored(baseline.category)) changes.category = category;
if (!sameTags(tags, baseline.tags)) changes.tags = tags;
if (draft.rating !== baseline.rating) changes.rating = draft.rating;
if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes;
return changes;
}
function createMetadata(draft) {
return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags),
rating: draft.rating, notes: draft.notes};
notes: draft.notes};
}
// A blank category is stored as NULL; "Uncategorized" is only the UI's label for it.
const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value));
function sameMetadata(sent, owner) {
return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) &&
sent.rating === owner.rating && sent.notes === (owner.notes ?? '');
sent.notes === (owner.notes ?? '');
}
function draftProblem(mode, draft) {
if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'});
@@ -328,9 +322,8 @@ export function createLibraryEditorController({api, mode, positiveId = null, pai
},
setField(name, value) {
if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`);
const next = name === 'rating' ? normalizeRating(value) : value;
if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`);
emit({draft: {...state.draft, [name]: next}});
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
emit({draft: {...state.draft, [name]: value}});
},
async submit() {
if (disposed || !SUBMITTABLE.has(state.phase)) return;
+8 -11
View File
@@ -7,12 +7,11 @@
* module only renders `controller.state` and forwards user actions.
*/
import { createButton } from '../shared/toolkit/core.js';
import { createLibraryEditorController } from './library-editor-state.mjs';
import { createLibraryEditorController, EDITOR_FIELDS } from './library-editor-state.mjs';
const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'};
const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']];
const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category',
tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'};
tags: 'recipe-tags', notes: 'recipe-notes'};
const SUBMITTABLE = new Set(['ready', 'write-error']);
const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.';
const NO_PAIR = 'Archive needs a selected saved negative variant.';
@@ -218,9 +217,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
category.setAttribute('list', 'categories');
tagInput.setAttribute('list', 'tag-options');
columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags));
const rating = controls.rating = el('select');
for (const [value, text] of RATINGS) rating.add(new Option(text, value));
form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes));
// D153 (#447): no Rating field; the concept's `#recipe-rating` select is not drawn.
form.append(columns, categories, tags, field('Notes', textarea('notes'), FIELD_IDS.notes));
} else form.append(el('p', NEGATIVE_HELP, 'small'));
const line = statusLine(), recovery = new Map();
const actions = el('div', undefined, 'dialog-actions');
@@ -230,9 +228,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
form.append(line.note, actions);
for (const [name, node] of Object.entries(controls)) {
node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => {
controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value);
}, {signal: views.signal});
node.addEventListener('input', () => controller.setField(name, node.value), {signal: views.signal});
}
form.addEventListener('submit', event => {
event.preventDefault();
@@ -246,7 +242,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
let categoryKey = null, refocus = null;
function render(state) {
for (const [name, node] of Object.entries(controls)) {
const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name];
const value = state.draft[name];
if (node.value !== value) node.value = value;
}
const key = state.categories.join('\n');
@@ -274,7 +270,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
body.replaceChildren(form);
return {controller, views, render, start() {
if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value);
// A kept draft carries only the editor's own fields (D153: never a rating).
if (draft) for (const [name, value] of Object.entries(draft)) if (EDITOR_FIELDS.includes(name)) controller.setField(name, value);
render(controller.state);
controller.initialize();
}};
-6
View File
@@ -60,9 +60,3 @@ export function usedLabel(lastUsedAt, now = new Date()) {
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
}
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
export function ratingLabel(rating) {
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
}
+1 -1
View File
@@ -11,7 +11,7 @@ function parameters(filters,limit) {
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
const sort=filters.sort || 'newest';
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
query.set('sort',sort);return query;
}
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
+10 -10
View File
@@ -6,7 +6,7 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr
import {promptRoles} from './prompt-blocks.mjs';
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
import {createLibraryEditorController} from './library-editor-state.mjs';
import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs';
import {layoutLibraryRows,visibleLibraryRows,usedLabel} from './library-layout.mjs';
import {createWorkspaceIcon} from './icons.mjs';
import {mountRecordedHistory} from './history.mjs';
import {countOf} from './plural.mjs';
@@ -29,8 +29,8 @@ const selectedPairs=new Map();
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's three sorts first, then the Library's appended Oldest first (D14).
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
const CARD_CONTROLS='button, input[type="checkbox"]';
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
@@ -111,7 +111,7 @@ function outcomeMessage(result,outcome) {
if(kind!=='create' && kind!=='add-negative') return null;
if(receipt.positive_created) return 'Saved.';
if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.'
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.'
:'Saved as a new negative variant of an existing prompt.';
if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
return 'This prompt pair already exists. Showing the saved version.';
@@ -122,7 +122,7 @@ function takeSavedSelection(positiveId) {
}
function rememberUnappliedMetadata(result,{outcome,draft}={}) {
if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return;
const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes});
const {title:name,category,tags,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,notes});
}
/** New prompt, from Library or Images: a saved prompt always opens its own history, as in the concept. */
function createNewPromptEditor(api,scope,main) {
@@ -159,6 +159,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
const restorable=visit?.state?visit:null;
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
// D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added.
if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest';
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
@@ -220,7 +222,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
/** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */
/** The concept's prompt card (app.js renderLibrary a586) with real counts and last use (#249 D23; no rating, D153). */
function cardFor(item,index) {
const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
@@ -246,10 +248,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip'));
content.append(heading,prompts,meta,vocabulary);
const footer=el('div',undefined,'card-footer');
const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at);
const facts=el('span');
if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);}
else facts.textContent=used;
// D153 (#447): the footer reads only the last use; the concept's rating stars are not drawn.
const facts=el('span',usedLabel(item.last_used_at));
const actions=el('div',undefined,'row-actions');
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
+4 -3
View File
@@ -6,7 +6,8 @@
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
* accepts is dropped to that key's default, so an opened view applies what it can.
* accepts is dropped to that key's default, so an opened view applies what it can (a view saved under the retired
* "Highest rated" sort opens on Recently added, D153).
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
*/
@@ -36,7 +37,7 @@ const tags = { fallback: [], accepts: value => Array.isArray(value) && value.len
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
const FIELDS = Object.freeze({
images: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc'], 'newest'),
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
@@ -45,7 +46,7 @@ const FIELDS = Object.freeze({
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
}),
});
+9 -13
View File
@@ -11,7 +11,8 @@
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
* - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals".
* - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or
* rated-images total).
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
@@ -168,21 +169,16 @@ export function healthSummary(report) {
return `${time}Nothing was changed; review the checks with issues.`;
}
/** D96: the concept's "Runs by category" rows `[category, runs, average rating]`. */
/** D96: the concept's "Runs by category" rows `[category, runs]`; D153 drops its average rating column. */
export function categoryRows(statistics) {
return statistics.by_category.map(row => [
row.category ?? 'Uncategorized',
number(row.runs),
row.average_rating === null || row.average_rating === undefined ? 'Not rated'
: `${Number(row.average_rating).toLocaleString(undefined, { maximumFractionDigits: 2 })} / 5`,
]);
return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]);
}
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics and the rated images). */
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */
export function totalsRows(statistics) {
const { totals } = statistics;
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images],
['Rated images', statistics.rated_images]].map(([label, value]) => [label, number(value)]);
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]]
.map(([label, value]) => [label, number(value)]);
}
function table(caption, head, rows, empty) {
@@ -215,7 +211,7 @@ function table(caption, head, rows, empty) {
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
export function statisticsNodes(statistics) {
return [
table('Runs by category', ['Category', 'Runs', 'Avg. rating'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
];
}
@@ -378,7 +374,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
label: 'Appearance', onClick: go('appearance') }),
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
operationRow({ scope, title: 'Statistics', description: 'Runs and ratings by category, with library totals.', label: 'Explore',
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore',
onClick: openStatistics }),
]);
const columns = el('div', undefined, 'two-col');
+46 -2
View File
@@ -14,6 +14,9 @@
* it to the host draft of `gallery_root_paths`; Remove takes one out. Both wait for the one Save row.
* - "Watch for new outputs" (`monitoring_enabled`) and "Worker threads" (`worker_threads`, a range up to the server's
* `cpu_count`, D113) are host drafts too. "Enrich existing metadata" reviews a scan of every source.
* - D158 (#247): "Save prompts from other workflows" (`save_foreign_runs`), a host draft below "Watch for new outputs",
* applied by the server at once (the monitor reads it for every file); disabled, saying why, while monitoring is off,
* unless the running monitor still watches until a restart (D128 `stopping`): then it can still be set.
* - "Thumbnail generation → Review options" opens "Thumbnail maintenance" (thumbnail-review.mjs, S5 Task 14), which
* starts the thumbnail Activity job.
*
@@ -63,6 +66,21 @@ export const LOAD_FAILED_TEXT = 'Sources could not be loaded. Your saved data ha
export const DUPLICATE_TEXT = 'This folder is already a source.';
export const VALIDATE_FAILED_TEXT = 'The folder could not be checked. Try again.';
export const REFRESH_FAILED_TEXT = 'Files could not be refreshed. Try again.';
/** D158 (#247): the row the concept does not draw, below "Watch for new outputs". */
export const FOREIGN_RUNS = Object.freeze({ id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows',
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.',
off: 'Applies while Watch for new outputs is on.' });
/**
* D158: the row for the drafted `save_foreign_runs` (`value`) and `monitoring_enabled` (`monitoring`): disabled until
* read, and while monitoring is off, when `note` says why (the value is kept and saved as it is). `stopping`: a source
* is still watched until the restart (D128), so the value can still be set; the note stays.
*/
export function foreignRunsState({ value, monitoring, stopping = false }) {
if (value === undefined) return { disabled: true, checked: null, note: null };
const off = monitoring === false;
return { disabled: off && !stopping, checked: value === true, note: off ? FOREIGN_RUNS.off : null };
}
// Labels of the sources added in this page session (validate-source answers them), for the rows not saved yet.
const addedLabels = new Map();
@@ -267,13 +285,16 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
const monitoring = settingRow({ id: 'monitoring', label: 'Watch for new outputs',
description: 'New files enter the same library. Existing scans do not count as new runs.', control: { type: 'toggle' },
scope, onChange: value => drafts.set('host', 'monitoring_enabled', value) });
const foreign = settingRow({ id: FOREIGN_RUNS.id, label: FOREIGN_RUNS.label, description: FOREIGN_RUNS.description,
control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', FOREIGN_RUNS.key, value) });
const workers = settingRow({ id: 'workers', label: 'Worker threads',
description: 'Balance background scans and thumbnail work against generation.', control: { type: 'range', value: 4 },
scope, onChange: value => drafts.set('host', 'worker_threads', value) });
const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [workers, 'workers', 'worker_threads']]
const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [foreign, FOREIGN_RUNS.id, FOREIGN_RUNS.key],
[workers, 'workers', 'worker_threads']]
.map(([row, id, key]) => ({ input: controlOf(row, id), key, text: row.children[0], note: null }));
for (const row of hostRows) row.input.dataset.key = row.key;
surface.append(monitoring, workers,
surface.append(monitoring, foreign, workers,
operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.',
label: 'Review scan', onClick: scanAll }),
operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.',
@@ -348,6 +369,10 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
for (const row of hostRows) {
const value = drafts.get('host', row.key);
row.input.disabled = value === undefined;
if (row.key === FOREIGN_RUNS.key) {
paintForeign(row, value, unavailable);
continue;
}
if (row.key === 'worker_threads') {
// D113: the range ends at the server's core count (the concept's fixed 16).
const cpus = drafts.get('host', 'cpu_count');
@@ -360,6 +385,24 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
}
}
// D158: disabled while monitoring is drafted off, with the reason under the description.
function paintForeign(row, value, unavailable) {
const stopping = sources?.effective?.some(source => source.watch === 'stopping') === true;
const state = foreignRunsState({ value, monitoring: drafts.get('host', 'monitoring_enabled'), stopping });
row.input.disabled = state.disabled;
if (state.checked !== null) row.input.checked = state.checked;
const note = unavailable && value === undefined ? HOST_UNAVAILABLE : state.note;
if (note && row.note?.textContent !== note) {
row.note?.remove();
row.note = el('p', note, 'small');
row.note.id = `${FOREIGN_RUNS.id}-note`;
row.text.append(row.note);
} else if (!note && row.note) { row.note.remove(); row.note = null; }
// The note is the switch's description, so a screen reader says why it is disabled.
if (row.note) row.input.setAttribute('aria-describedby', row.note.id);
else row.input.removeAttribute('aria-describedby');
}
// The sources changed since this page read them (a refused per-source scan): read them and the saved values again.
function reread() {
load();
@@ -378,6 +421,7 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
if (answer === STALE || scope.signal.aborted) return;
sources = answer?.sources ?? null;
paintList();
paintControls();
}
// A save (or a reread after a conflict) changed the saved sources or monitoring: read their state again. The first
+1
View File
@@ -13,6 +13,7 @@ export const SEARCH_DELAY = 250;
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
// other label is one of the page's appended options.
// No page passes "Highest rated" since D153 (#447); it stays here only to keep the concept's order.
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
const isText = value => typeof value === 'string';
+12
View File
@@ -169,6 +169,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; }
.list-grid .image-open video { height: 95px; aspect-ratio: auto; }
/* #451 D157: a cover crop of a generated image keeps its top (heads, faces) instead of its middle. The vertical 20%
acts only when the image is taller than its box; a wider image overflows sideways and stays centred. Masonry shows
the whole image (contain) and keeps the centre. */
.image-grid:not(.masonry-grid) .image-open :is(img, video), .card-art img, .detail-cover, .tag-image-preview {
object-position: 50% 20%; /* D157 */
}
/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media,
titles and states that the concept's one demo image never shows. */
/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the
@@ -230,6 +237,11 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
details, and an unavailable download stays visibly inert next to its reason. */
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
/* #449 D154: the image's own tags and the linked prompt's or recipe's tags sit in the prompt sections' frame (concept
.prompt-section), under a muted .role-label; the heading's 8px spaces the chips, as it spaces a prompt's text. */
.file-inspector .tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.file-inspector .tag-section .role-label { color: var(--ca-muted); }
.file-inspector .tag-section > .tags { margin-top: 0; }
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }